PMFlow/backend/app/services/model_context_bundle_servic...

436 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Model context bundle service.
统一提供三类上下文:
1. 知识图谱摘要(KG overview)
2. 任务模板(task briefs)
3. 契约同步状态(contract sync)
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Optional
from uuid import uuid4
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import AICallLog
from .model_routing_service import ModelRoutingService
from .project_knowledge_graph import ProjectKnowledgeGraphService
from .task_brief_service import (
HIGH_CONTRACT_TEMPLATE_ID,
HIGH_TEMPLATE_ID,
LOW_TEMPLATE_ID,
TaskBriefService,
)
CONTEXT_BUNDLE_MARKER = "PMFLOW_CONTEXT_BUNDLE_V1"
CONTEXT_BUNDLE_KEYS = ("knowledge_graph", "task_template", "contract_sync")
TARGET_FUNCTION_CONTEXT: dict[str, dict[str, Any]] = {
"classify_intent_ai_pmo_kernel": {
"tier": "low",
"template_ids": [LOW_TEMPLATE_ID],
},
"signal_planner_analyze": {
"tier": "low",
"template_ids": [LOW_TEMPLATE_ID],
},
"classify_intent_complex": {
"tier": "high",
"template_ids": [HIGH_TEMPLATE_ID, HIGH_CONTRACT_TEMPLATE_ID],
},
}
class ModelContextBundleService:
@staticmethod
def _normalize_function_name(function_name: str) -> str:
return str(function_name or "").strip().lower()
@classmethod
def target_functions(cls) -> list[str]:
return list(TARGET_FUNCTION_CONTEXT.keys())
@classmethod
def should_inject_context(cls, function_name: str) -> bool:
fn = cls._normalize_function_name(function_name)
return fn in TARGET_FUNCTION_CONTEXT
@staticmethod
def _template_snapshot(template: Optional[dict[str, Any]], *, include_content: bool = True) -> Optional[dict[str, Any]]:
if not isinstance(template, dict):
return None
content = str(template.get("content") or "")
payload: dict[str, Any] = {
"id": str(template.get("id") or ""),
"name": str(template.get("name") or ""),
"description": str(template.get("description") or ""),
"updated_at": str(template.get("updated_at") or ""),
}
if include_content:
payload["content"] = content[:2400]
return payload
@staticmethod
def _kg_snapshot(overview: dict[str, Any]) -> dict[str, Any]:
latest = overview.get("latest_sync_run") if isinstance(overview.get("latest_sync_run"), dict) else {}
return {
"entity_total": int(overview.get("entity_total") or 0),
"relation_total": int(overview.get("relation_total") or 0),
"evidence_total": int(overview.get("evidence_total") or 0),
"pending_events": int(overview.get("pending_events") or 0),
"latest_sync_run": {
"id": latest.get("id"),
"run_type": str(latest.get("run_type") or ""),
"status": str(latest.get("status") or ""),
"started_at": latest.get("started_at"),
"ended_at": latest.get("ended_at"),
}
if latest
else None,
}
@staticmethod
def _contract_snapshot(contract_sync: dict[str, Any]) -> dict[str, Any]:
files = contract_sync.get("files") if isinstance(contract_sync.get("files"), list) else []
normalized_files: list[dict[str, Any]] = []
missing_files: list[str] = []
for item in files:
if not isinstance(item, dict):
continue
exists = bool(item.get("exists"))
file_path = str(item.get("file") or "")
if file_path and not exists:
missing_files.append(file_path)
normalized_files.append(
{
"file": file_path,
"exists": exists,
"updated_at": item.get("updated_at"),
}
)
return {
"all_synced": bool(contract_sync.get("all_synced")),
"missing_files": missing_files,
"files": normalized_files,
}
@classmethod
async def build_context_bundle(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
) -> Optional[dict[str, Any]]:
fn = cls._normalize_function_name(function_name)
target = TARGET_FUNCTION_CONTEXT.get(fn)
if target is None:
return None
kg_service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id))
overview = await kg_service.get_overview()
templates = await TaskBriefService.list_templates(db, tenant_id=int(tenant_id))
template_map = {
str(item.get("id") or ""): item
for item in templates
if isinstance(item, dict) and str(item.get("id") or "").strip()
}
configured_template_ids = [str(x or "").strip() for x in target.get("template_ids", []) if str(x or "").strip()]
primary_template_id = configured_template_ids[0] if configured_template_ids else ""
additional_ids = configured_template_ids[1:] if len(configured_template_ids) > 1 else []
missing_template_ids = [tid for tid in configured_template_ids if tid not in template_map]
contract_sync = await TaskBriefService.get_contract_sync_status(db, tenant_id=int(tenant_id))
bundle = {
"marker": CONTEXT_BUNDLE_MARKER,
"function_name": fn,
"tier": str(target.get("tier") or ""),
"knowledge_graph": cls._kg_snapshot(overview),
"task_template": {
"primary": cls._template_snapshot(template_map.get(primary_template_id), include_content=True),
"additional": [
snap
for snap in (
cls._template_snapshot(template_map.get(template_id), include_content=False)
for template_id in additional_ids
)
if snap is not None
],
"template_ids": configured_template_ids,
"missing_template_ids": missing_template_ids,
},
"contract_sync": cls._contract_snapshot(contract_sync),
}
return bundle
@classmethod
def render_bundle_marker_text(cls, bundle: dict[str, Any]) -> str:
payload = dict(bundle) if isinstance(bundle, dict) else {}
marker = f"[{CONTEXT_BUNDLE_MARKER}]"
return f"{marker}\n{json.dumps(payload, ensure_ascii=False, sort_keys=True)}"
@classmethod
async def inject_prompt_with_bundle(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
prompt: str,
) -> str:
raw_prompt = str(prompt or "")
if not cls.should_inject_context(function_name):
return raw_prompt
marker = f"[{CONTEXT_BUNDLE_MARKER}]"
if marker in raw_prompt:
return raw_prompt
bundle = await cls.build_context_bundle(
db,
tenant_id=int(tenant_id),
function_name=function_name,
)
if bundle is None:
return raw_prompt
bundle_text = cls.render_bundle_marker_text(bundle)
return f"{bundle_text}\n\n{raw_prompt}".strip()
@classmethod
async def build_capability_summary(cls, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]:
kg_service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id))
overview = await kg_service.get_overview()
templates = await TaskBriefService.list_templates(db, tenant_id=int(tenant_id))
template_map = {
str(item.get("id") or ""): item
for item in templates
if isinstance(item, dict) and str(item.get("id") or "").strip()
}
required_template_ids = sorted(
{
template_id
for spec in TARGET_FUNCTION_CONTEXT.values()
for template_id in spec.get("template_ids", [])
if str(template_id or "").strip()
}
)
present_template_ids = sorted(template_map.keys())
missing_template_ids = [template_id for template_id in required_template_ids if template_id not in template_map]
contract_sync_raw = await TaskBriefService.get_contract_sync_status(db, tenant_id=int(tenant_id))
contract_sync = cls._contract_snapshot(contract_sync_raw)
function_matrix: list[dict[str, Any]] = []
for function_name in cls.target_functions():
spec = TARGET_FUNCTION_CONTEXT[function_name]
routing = await ModelRoutingService.get_effective_routing(
db,
tenant_id=int(tenant_id),
function_name=function_name,
)
function_matrix.append(
{
"function_name": function_name,
"tier": str(spec.get("tier") or ""),
"context_sections": list(CONTEXT_BUNDLE_KEYS),
"template_ids": [str(tid) for tid in spec.get("template_ids", []) if str(tid or "").strip()],
"model_routing": {
"provider": str(routing.get("provider") or ""),
"model": str(routing.get("model") or ""),
"tier": str(routing.get("tier") or ""),
"source": str(routing.get("source") or ""),
"api_env": str(routing.get("api_env") or ""),
},
}
)
required_templates = [
cls._template_snapshot(template_map.get(template_id), include_content=False) or {"id": template_id}
for template_id in required_template_ids
]
return {
"marker": CONTEXT_BUNDLE_MARKER,
"knowledge_graph": cls._kg_snapshot(overview),
"task_templates": {
"total": len(templates),
"required_template_ids": required_template_ids,
"present_template_ids": present_template_ids,
"missing_template_ids": missing_template_ids,
"required_templates": required_templates,
},
"contract_sync": contract_sync,
"function_model_template_matrix": function_matrix,
"coverage": {
"template_ready": len(missing_template_ids) == 0,
"contract_ready": bool(contract_sync.get("all_synced")),
"target_function_count": len(function_matrix),
},
"generated_at": datetime.now(timezone.utc).isoformat(),
}
@classmethod
def _canary_prompt_for_function(cls, *, function_name: str, probe_id: str) -> str:
fn = cls._normalize_function_name(function_name)
if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}:
return (
f"这是 PMFlow 上下文注入探针,probe_id={probe_id}。"
"请只返回 JSON:"
'{"intent":"query.read_only","confidence":0.91,"reasoning":"probe_context_ok"}'
)
if fn == "signal_planner_analyze":
return (
f"这是 PMFlow 上下文注入探针,probe_id={probe_id}。"
"请只返回 JSON:"
'{"summary":"probe","priority":"medium","execution_plan":[],"risks":[],"confidence":0.77}'
)
return f"probe_id={probe_id}"
@classmethod
async def _trigger_canary_call(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
probe_id: str,
) -> dict[str, Any]:
prompt = cls._canary_prompt_for_function(function_name=function_name, probe_id=probe_id)
try:
from .llm import llm_service # 延迟导入,避免循环依赖
result = await llm_service.generate_structured_object(
prompt,
db=db,
tenant_id=int(tenant_id),
function_name=function_name,
use_prompt_profile=False,
temperature=0.0,
max_tokens=220,
response_mode="fast",
)
return {
"called": True,
"error": None,
"error_code": str(result.error_code or ""),
"status": str(result.error_code or "success"),
"provider": str(result.provider or ""),
"model": str(result.model or ""),
}
except Exception as exc:
return {
"called": False,
"error": str(exc)[:500],
"error_code": "canary_call_failed",
"status": "failed",
"provider": "",
"model": "",
}
@classmethod
async def _find_prompt_evidence(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
probe_id: Optional[str],
) -> Optional[AICallLog]:
stmt = select(AICallLog).where(
and_(
AICallLog.tenant_id == int(tenant_id),
AICallLog.function_name == str(function_name),
AICallLog.prompt.like(f"%[{CONTEXT_BUNDLE_MARKER}]%"),
)
)
if probe_id:
stmt = stmt.where(AICallLog.prompt.like(f"%{probe_id}%"))
stmt = stmt.order_by(AICallLog.id.desc()).limit(1)
return (await db.execute(stmt)).scalar_one_or_none()
@classmethod
async def verify_context_consumption(
cls,
db: AsyncSession,
*,
tenant_id: int,
run_canary: bool = True,
) -> dict[str, Any]:
probe_id = uuid4().hex[:12] if run_canary else ""
function_results: list[dict[str, Any]] = []
for function_name in cls.target_functions():
target = TARGET_FUNCTION_CONTEXT.get(function_name) or {}
canary = {"called": False, "status": "skipped", "error": None, "error_code": "", "provider": "", "model": ""}
if run_canary:
canary = await cls._trigger_canary_call(
db,
tenant_id=int(tenant_id),
function_name=function_name,
probe_id=probe_id,
)
evidence = await cls._find_prompt_evidence(
db,
tenant_id=int(tenant_id),
function_name=function_name,
probe_id=probe_id if run_canary else None,
)
prompt_text = str(evidence.prompt or "") if evidence is not None else ""
checks = {
"marker": f"[{CONTEXT_BUNDLE_MARKER}]" in prompt_text,
"knowledge_graph": '"knowledge_graph"' in prompt_text,
"task_template": '"task_template"' in prompt_text,
"contract_sync": '"contract_sync"' in prompt_text,
}
route = await ModelRoutingService.get_effective_routing(
db,
tenant_id=int(tenant_id),
function_name=function_name,
)
function_results.append(
{
"function_name": function_name,
"tier": str(target.get("tier") or ""),
"template_ids": [str(x) for x in target.get("template_ids", []) if str(x or "").strip()],
"routing_snapshot": {
"provider": str(route.get("provider") or ""),
"model": str(route.get("model") or ""),
"tier": str(route.get("tier") or ""),
"source": str(route.get("source") or ""),
},
"canary": canary,
"checks": checks,
"pass": all(checks.values()),
"log_evidence": (
{
"id": int(evidence.id),
"status": str(evidence.status or ""),
"created_at": evidence.created_at.isoformat() if evidence.created_at else None,
"prompt_excerpt": prompt_text[:800],
}
if evidence is not None
else None
),
}
)
tier_result: dict[str, dict[str, Any]] = {"low": {"pass": False, "functions": []}, "high": {"pass": False, "functions": []}}
for tier_name in ("low", "high"):
tier_functions = [row for row in function_results if str(row.get("tier") or "") == tier_name]
tier_result[tier_name] = {
"pass": bool(tier_functions) and all(bool(row.get("pass")) for row in tier_functions),
"functions": tier_functions,
}
overall_pass = bool(tier_result["low"]["pass"]) and bool(tier_result["high"]["pass"])
return {
"marker": CONTEXT_BUNDLE_MARKER,
"run_canary": bool(run_canary),
"probe_id": probe_id or None,
"overall_pass": overall_pass,
"tiers": tier_result,
"functions": function_results,
}