287 lines
10 KiB
Python

"""
数据集市 ETL — 增量同步 llmusage → 集市表
"""
import json
import asyncio
from datetime import datetime, timedelta
from appPublic.uniqueID import getID
from appPublic.dictObject import DictObject
from appPublic.jsonConfig import getConfig
from appPublic.log import debug, info, exception
from sqlor.dbpools import DBPools
from ahserver.serverenv import ServerEnv
MODULE_NAME = "sage_datamark"
async def ensure_tables(sor):
"""创建集市表(如不存在)"""
tables = [
"""CREATE TABLE IF NOT EXISTS dm_model_call_fact (
id VARCHAR(32) PRIMARY KEY,
luid VARCHAR(32) NOT NULL,
llmid VARCHAR(32) NOT NULL,
model VARCHAR(100),
catelogid VARCHAR(10),
userid VARCHAR(32) NOT NULL,
userorgid VARCHAR(32) NOT NULL,
ownerid VARCHAR(32),
providerid VARCHAR(32),
call_date DATE NOT NULL,
call_hour TINYINT NOT NULL,
call_time DATETIME NOT NULL,
ttft_ms INT,
ttot_ms INT,
prompt_tokens INT DEFAULT 0,
completion_tokens INT DEFAULT 0,
status VARCHAR(20),
fail_reason VARCHAR(200),
amount DECIMAL(10,4) DEFAULT 0,
currency VARCHAR(5) DEFAULT 'CNY',
distributor_orgid VARCHAR(32),
sale_userid VARCHAR(32),
created_at DATETIME DEFAULT NOW(),
INDEX idx_call_date (call_date),
INDEX idx_llmid (llmid),
INDEX idx_userorgid (userorgid),
INDEX idx_ownerid (ownerid),
INDEX idx_status (status),
INDEX idx_model (model)
)""",
"""CREATE TABLE IF NOT EXISTS dm_model_perf_daily (
id VARCHAR(32) PRIMARY KEY,
stat_date DATE NOT NULL,
llmid VARCHAR(32) NOT NULL,
model VARCHAR(100),
catelogid VARCHAR(10),
providerid VARCHAR(32),
ownerid VARCHAR(32),
total_calls INT DEFAULT 0,
success_calls INT DEFAULT 0,
fail_calls INT DEFAULT 0,
success_rate DECIMAL(5,2),
avg_ttft_ms DECIMAL(10,1),
p50_ttft_ms DECIMAL(10,1),
p95_ttft_ms DECIMAL(10,1),
p99_ttft_ms DECIMAL(10,1),
avg_ttot_ms DECIMAL(10,1),
p50_ttot_ms DECIMAL(10,1),
p95_ttot_ms DECIMAL(10,1),
max_concurrent INT,
avg_concurrent DECIMAL(5,1),
total_prompt_tokens BIGINT DEFAULT 0,
total_completion_tokens BIGINT DEFAULT 0,
total_amount DECIMAL(12,4) DEFAULT 0,
userorgid VARCHAR(32),
distributor_orgid VARCHAR(32),
UNIQUE KEY uk_date_model_org (stat_date, llmid, userorgid),
INDEX idx_date (stat_date),
INDEX idx_model (model)
)""",
"""CREATE TABLE IF NOT EXISTS dm_provider_cost_daily (
id VARCHAR(32) PRIMARY KEY,
stat_date DATE NOT NULL,
model VARCHAR(100) NOT NULL,
catelogid VARCHAR(10),
providerid VARCHAR(32) NOT NULL,
provider_name VARCHAR(100),
total_calls INT DEFAULT 0,
total_tokens BIGINT DEFAULT 0,
total_amount DECIMAL(12,4) DEFAULT 0,
unit_price DECIMAL(10,6),
avg_ttft_ms DECIMAL(10,1),
UNIQUE KEY uk_date_model_provider (stat_date, model, providerid),
INDEX idx_model (model),
INDEX idx_date (stat_date)
)""",
]
for sql in tables:
await sor.sqlExe(sql, {})
def parse_usages(usages_str):
"""从 llmusage.usages JSON 中提取 token 数"""
try:
u = json.loads(usages_str) if isinstance(usages_str, str) else usages_str
if isinstance(u, dict):
return (
u.get('prompt_tokens', 0) or 0,
u.get('completion_tokens', 0) or 0,
u.get('total_tokens', 0) or 0
)
except:
pass
return 0, 0, 0
async def sync_call_fact(sor, last_sync=None):
"""增量同步 llmusage → dm_model_call_fact"""
if last_sync is None:
last_sync = (datetime.now() - timedelta(minutes=10)).strftime('%Y-%m-%d %H:%M:%S')
sql = """
SELECT lu.*, l.model as llm_model, l.catelogid, l.providerid, l.ownerid
FROM llmusage lu
JOIN llm l ON l.id = lu.llmid
WHERE lu.use_time >= ${last_sync}$
ORDER BY lu.use_time
"""
rows = await sor.sqlExe(sql, {'last_sync': last_sync})
info('sage_datamark ETL: syncing ' + str(len(rows)) + ' rows from ' + last_sync)
count = 0
for r in rows:
r = DictObject(r)
# Skip existing
existing = await sor.sqlExe(
'SELECT id FROM dm_model_call_fact WHERE luid=${luid}$',
{'luid': r.id})
if existing:
continue
prompt_tokens, completion_tokens, _ = parse_usages(r.usages)
call_time = datetime.strptime(r.use_time[:19], '%Y-%m-%d %H:%M:%S')
ns = {
'id': getID(),
'luid': r.id,
'llmid': r.llmid,
'model': r.llm_model or r.model,
'catelogid': r.catelogid,
'userid': r.userid,
'userorgid': r.userorgid,
'ownerid': r.ownerid,
'providerid': r.providerid,
'call_date': r.use_date or call_time.strftime('%Y-%m-%d'),
'call_hour': call_time.hour,
'call_time': r.use_time,
'ttft_ms': int(r.responsed_seconds * 1000) if r.responsed_seconds else None,
'ttot_ms': int(r.finish_seconds * 1000) if r.finish_seconds else None,
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
'status': r.status or 'SUCCEEDED',
'amount': r.amount or 0,
'currency': getattr(r, 'currency', 'CNY') or 'CNY',
}
await sor.C('dm_model_call_fact', ns)
count += 1
info('sage_datamark ETL: inserted ' + str(count) + ' new rows')
return count
async def aggregate_daily_perf(sor, stat_date=None):
"""聚合当天 dm_model_call_fact → dm_model_perf_daily"""
if stat_date is None:
stat_date = datetime.now().strftime('%Y-%m-%d')
# 按 llmid + userorgid 聚合
sql = """
SELECT
call_date, llmid, model, catelogid, providerid, ownerid,
COALESCE(userorgid, '') as userorgid,
COUNT(*) as total_calls,
SUM(CASE WHEN status='SUCCEEDED' THEN 1 ELSE 0 END) as success_calls,
SUM(CASE WHEN status='FAILED' THEN 1 ELSE 0 END) as fail_calls,
AVG(ttft_ms) as avg_ttft,
AVG(ttot_ms) as avg_ttot,
SUM(prompt_tokens) as total_prompt,
SUM(completion_tokens) as total_completion,
SUM(amount) as total_amount
FROM dm_model_call_fact
WHERE call_date = ${stat_date}$
GROUP BY call_date, llmid, model, catelogid, providerid, ownerid, userorgid
"""
rows = await sor.sqlExe(sql, {'stat_date': stat_date})
info('sage_datamark AGG: aggregating ' + str(len(rows)) + ' groups for ' + stat_date)
for r in rows:
r = DictObject(r)
# Delete old row, insert new
await sor.sqlExe(
'DELETE FROM dm_model_perf_daily WHERE stat_date=${d}$ AND llmid=${l}$ AND COALESCE(userorgid,\"\")=${u}$',
{'d': stat_date, 'l': r.llmid, 'u': r.userorgid or ''})
ns = {
'id': getID(),
'stat_date': stat_date,
'llmid': r.llmid,
'model': r.model,
'catelogid': r.catelogid,
'providerid': r.providerid,
'ownerid': r.ownerid,
'total_calls': r.total_calls,
'success_calls': r.success_calls,
'fail_calls': r.fail_calls,
'success_rate': round(r.success_calls * 100.0 / r.total_calls, 2) if r.total_calls else 0,
'avg_ttft_ms': round(r.avg_ttft, 1) if r.avg_ttft else None,
'avg_ttot_ms': round(r.avg_ttot, 1) if r.avg_ttot else None,
'total_prompt_tokens': r.total_prompt or 0,
'total_completion_tokens': r.total_completion or 0,
'total_amount': r.total_amount or 0,
'userorgid': r.userorgid or None,
}
await sor.C('dm_model_perf_daily', ns)
return len(rows)
async def aggregate_provider_cost(sor, stat_date=None):
"""聚合同模型供应商性价比 — dm_provider_cost_daily"""
if stat_date is None:
stat_date = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
sql = """
SELECT
model, providerid,
COUNT(*) as total_calls,
SUM(prompt_tokens + completion_tokens) as total_tokens,
SUM(amount) as total_amount,
AVG(ttft_ms) as avg_ttft
FROM dm_model_call_fact
WHERE call_date = ${stat_date}$
GROUP BY model, providerid
"""
rows = await sor.sqlExe(sql, {'stat_date': stat_date})
for r in rows:
r = DictObject(r)
await sor.sqlExe(
'DELETE FROM dm_provider_cost_daily WHERE stat_date=${d}$ AND model=${m}$ AND providerid=${p}$',
{'d': stat_date, 'm': r.model, 'p': r.providerid})
ns = {
'id': getID(),
'stat_date': stat_date,
'model': r.model,
'providerid': r.providerid,
'total_calls': r.total_calls,
'total_tokens': r.total_tokens or 0,
'total_amount': r.total_amount or 0,
'unit_price': round(float(r.total_amount) / r.total_tokens, 8) if r.total_tokens else None,
'avg_ttft_ms': round(r.avg_ttft, 1) if r.avg_ttft else None,
}
await sor.C('dm_provider_cost_daily', ns)
return len(rows)
async def run_etl_sync(sor):
"""每5分钟运行: 确保表存在 + 增量同步"""
await ensure_tables(sor)
await sync_call_fact(sor)
async def run_etl_aggregate(sor):
"""每小时运行: 聚合天级性能 + 并发统计"""
await ensure_tables(sor)
await aggregate_daily_perf(sor)
async def run_etl_provider_cost(sor):
"""每天运行: 供应商性价比"""
await ensure_tables(sor)
await aggregate_provider_cost(sor)