diff --git a/README.md b/README.md index c80ee41..a9f1e51 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,23 @@ 均可,Jinja `is string` 动态判断),变长输入用 `{% for %}` 按文档示例组装。 - **异步模型执行器**:sync_mode=async 时走提交→按 query_profile_ids 轮询→ 取结果;等待预算默认 600 秒,`_timeout` 可覆盖(上限 900)。 +- **会话 agent 模型选择收窄为对话能力(2026-09-06 用户定夺)**: + `selection.CHAT_CAPS = ('t2t','i2t','m2t')` 是会话形态能力白名单的**唯一事实源**—— + 所有会话 agent 模型下拉(agent_model_options / cockpit_model_options / + get_model_options 角色配置)传 `capabilities='chat'` 过滤;持久化入口 + (set_agent_model 个人默认 / cockpit_agent 角色模型 / pipeline_service + gateway._resolve_and_persist_model 项目模型)同样只接受对话能力,防绕过下拉。 + `chat_inference` 同步门禁从「只认 t2t」放宽到 CHAT_CAPS(实测根因:测试库 + 机构策略主模型 qwen3.8-max 能力是 i2t,旧门禁直接 FAILED + 「capability mismatch: i2t != t2t」,会话 agent 选它必挂)。 + 存量模型 `capability` 空串按 t2t 语义归一(COALESCE+NULLIF)。 + embedding/rerank/图视频生成等非对话模型各有专属入口(list_models 按能力分栏)。 +- **m2t(多媒体生文)入能力字典四处同步(m0013)**:种子 `init/data.json` + + 端点注释 `api/v1/models.dspy` + `docs/design-spec.md §6` + 提取提示词 + (`pipeline_platform.platform_ability._EXTRACT_PROMPT`,判定表补 + 「多媒体输入→文本 ⇒ m2t」「仅图像→文本 ⇒ i2t」「仅文本→文本 ⇒ t2t」, + 且 r2v 判定加「输出为视频」限定防误吸文本输出模型); + `_MEDIA_INPUT_CAPS` 加 m2t(模板生成按输入媒体处理)。 ## API 文档端点(2026-09-05) diff --git a/docs/design-spec.md b/docs/design-spec.md index 231dabb..71702d3 100644 --- a/docs/design-spec.md +++ b/docs/design-spec.md @@ -232,6 +232,7 @@ async def llm_call(prompt, model=None, org_id=None, user_id=None, task_ref='', * | t2t | 文本对话/生成 | sync | | t2i | 文生图 | async_task | | i2t | 图生文(多模态理解) | sync | +| m2t | 多媒体生文(视频/音频等多媒体输入→文本) | sync | | t2v | 文生视频 | async_task | | embedding | 文本向量化 | sync | | mm-embedding | 多模态向量化 | sync | diff --git a/init/data.json b/init/data.json index f5c84ab..1139fe0 100644 --- a/init/data.json +++ b/init/data.json @@ -15,6 +15,7 @@ {"k": "t2t", "v": "文本对话/生成"}, {"k": "t2i", "v": "文生图"}, {"k": "i2t", "v": "图生文"}, + {"k": "m2t", "v": "多媒体生文"}, {"k": "t2v", "v": "文生视频"}, {"k": "i2v", "v": "图生视频"}, {"k": "r2v", "v": "参考生视频"}, diff --git a/pipeline_llm/inference.py b/pipeline_llm/inference.py index 4de7316..aaa86ec 100644 --- a/pipeline_llm/inference.py +++ b/pipeline_llm/inference.py @@ -721,13 +721,16 @@ async def chat_inference(org_id, user_id, payload, model_name='', task_ref=''): model_row = ctx.get('model_row') or {} if (model_row.get('sync_mode') or 'sync') == 'async': return await _async_inference(ctx, payload, req_timeout) - # 同步模型守能力边界:聊天入口只认 t2t(embedding/rerank 等另有入口) + # 同步模型守能力边界:聊天入口只认文本输出的对话能力(t2t/i2t/m2t)—— + # embedding/rerank/图视频生成等另有专属入口(list_models 按能力分栏选择)。 + from .selection import CHAT_CAPS cap = (model_row.get('capability') or 't2t').strip().lower() - if cap != 't2t': + if cap not in CHAT_CAPS: from .gateway import govern_settle - await govern_settle(ctx, False, 0, 0, 'capability mismatch: %s != t2t' % cap) - raise GovernError('模型「%s」能力为 %s,聊天入口仅支持 t2t——请按能力分类选择模型' % ( - model_row.get('name', ''), cap)) + await govern_settle(ctx, False, 0, 0, + 'capability mismatch: %s not in chat caps' % cap) + raise GovernError('模型「%s」能力为 %s,聊天入口仅支持 %s——请按能力分类选择模型' % ( + model_row.get('name', ''), cap, '/'.join(CHAT_CAPS))) try: data = await _call_upstream_chat(ctx, payload) except Exception as e: diff --git a/pipeline_llm/selection.py b/pipeline_llm/selection.py index 8176e43..3d333e1 100644 --- a/pipeline_llm/selection.py +++ b/pipeline_llm/selection.py @@ -19,6 +19,23 @@ from ahserver.serverenv import ServerEnv logger = logging.getLogger("pipeline_llm.selection") +# 会话/chat 形态可用的能力类型(2026-09-06 用户定夺):文本输出的对话能力。 +# 会话 agent 模型下拉只列这些;embedding/rerank/图视频生成等形态各有专属入口。 +CHAT_CAPS = ('t2t', 'i2t', 'm2t') + + +def _norm_caps(capabilities): + """capabilities 参数归一:'chat' 哨兵 → CHAT_CAPS;逗号串 → tuple; + tuple/list 原样。返回 tuple(空 = 不过滤)。dspy 薄壳只传 'chat',零 import。""" + if isinstance(capabilities, str): + s = capabilities.strip().lower() + if not s: + return () + if s == 'chat': + return CHAT_CAPS + return tuple(c.strip().lower() for c in s.split(',') if c.strip()) + return tuple(str(c).strip().lower() for c in (capabilities or ()) if str(c or '').strip()) + def _get_sor(): env = ServerEnv() @@ -33,16 +50,21 @@ def _get_sor(): async def model_options(org_id, uid='', session_id='', pipeline_id='', - value_field='id'): + value_field='id', capabilities=()): """模型选择下拉的唯一数据源(对齐 llmage 分类:capability=能力类型)。 机构语义与推理链一致:本机构 + 系统级共享(org_id 空/'0')可见。 selected 标记:项目已设模型(sd_projects.default_model,存 name)> 个人全局选择(pipeline_agent_settings.default_llm_id,存 id)。 value_field: 'id'=下拉值用模型 id(AgentIO 场景);'name'=用注册名(角色模型配置场景)。 + capabilities: 能力类型白名单(tuple/list,如会话 agent 传 CHAT_CAPS=('t2t','i2t','m2t'))。 + 空 = 不过滤(历史行为)。注意:存量模型 capability 为空的行按 't2t' 语义对待, + 过滤时用 COALESCE+NULLIF 把空串归一成 't2t' 再比对(DDL 有默认值, + 但历史迁移行可能是空串)。 """ db, dbname = _get_sor() rows = [] + caps = _norm_caps(capabilities) async with db.sqlorContext(dbname) as sor: sql = ("SELECT m.id, m.name, m.vendor_model_id, m.capability, v.name AS vendor_name " "FROM llm_model m LEFT JOIN llm_vendor v ON v.id=m.vendor_id " @@ -51,6 +73,15 @@ async def model_options(org_id, uid='', session_id='', pipeline_id='', if org_id and org_id != '0': sql += " AND (m.org_id=${org}$ OR m.org_id='' OR m.org_id='0')" params['org'] = org_id + if caps: + # sqlor 的 IN 列表必须展开占位符(传 list 会崩):${c0}$,${c1}$,... + ph = [] + for i, c in enumerate(caps): + k = 'c%d' % i + ph.append('${%s}$' % k) + params[k] = c + sql += (" AND COALESCE(NULLIF(m.capability,''),'t2t') IN (%s)" + % ','.join(ph)) sql += " ORDER BY m.name" recs = await sor.sqlExe(sql, params) @@ -97,18 +128,22 @@ async def model_options(org_id, uid='', session_id='', pipeline_id='', return rows -async def resolve_model_name(model_ref, org_id=''): +async def resolve_model_name(model_ref, org_id='', capabilities=()): """模型引用解析的唯一入口:id / vendor_model_id / name → 模型注册名。 机构隔离:非系统级机构只能解析「本机构 + 系统级共享」模型。 + capabilities: 能力白名单('chat' 哨兵 / tuple),非空时能力不符也解析失败 + (会话 agent 个人默认模型/角色模型入口传 'chat',防把 embedding 等非对话 + 模型持久化成会话模型)。 解析不到返回 ''(调用方决定回退/报错,禁止自行另查表)。 """ if not model_ref: return '' + caps = _norm_caps(capabilities) db, dbname = _get_sor() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( - "SELECT name, org_id FROM llm_model WHERE status='active' " + "SELECT name, org_id, capability FROM llm_model WHERE status='active' " "AND (id=${m}$ OR vendor_model_id=${m}$ OR name=${m}$) LIMIT 1", {"m": model_ref}) await sor.sqlExe("COMMIT", {}) @@ -118,6 +153,12 @@ async def resolve_model_name(model_ref, org_id=''): if org_id and org_id != '0' and m_org not in ('', '0', org_id): logger.warning("resolve_model_name: 模型 %s 不属于机构 %s", model_ref, org_id) return '' + if caps: + cap = (getattr(recs[0], 'capability', '') or 't2t').strip().lower() + if cap not in caps: + logger.warning("resolve_model_name: 模型 %s 能力 %s 不在白名单 %s", + model_ref, cap, caps) + return '' return getattr(recs[0], 'name', '') or '' diff --git a/wwwroot/api/v1/models.dspy b/wwwroot/api/v1/models.dspy index 631a48c..5b88525 100644 --- a/wwwroot/api/v1/models.dspy +++ b/wwwroot/api/v1/models.dspy @@ -1,7 +1,7 @@ # models.dspy — 按能力分类列出可用模型(对齐 llmage v1/models 的分类方式) # # URL: /pipeline_llm/api/v1/models?catelogid=t2t -# catelogid 可选:t2t / t2i / t2v / i2v / r2v / i2i / embedding / rerank / tts / asr / i2t ... +# catelogid 可选:t2t / t2i / t2v / i2v / r2v / i2i / embedding / rerank / tts / asr / i2t / m2t ... # (能力类型标准 = 模型治理 appcodes llm_capability,新增走评审禁止野生标签) # 不传 = 全部能力 #