366 lines
17 KiB
Python
366 lines
17 KiB
Python
from __future__ import annotations
|
||
|
||
from .shared import *
|
||
|
||
class LLMGenerateDbStructuredMixin:
|
||
async def _generate_structured_with_db(
|
||
self,
|
||
db: AsyncSession,
|
||
prompt: str,
|
||
**kwargs: Any,
|
||
) -> StructuredExtractionResult:
|
||
tenant_id = int(kwargs.get("tenant_id") or 1)
|
||
user_id = self._safe_int(kwargs.get("user_id"))
|
||
function_name = str(kwargs.get("function_name") or "llm_generate").strip().lower() or "llm_generate"
|
||
temperature = float(kwargs.get("temperature") or 0.0)
|
||
max_tokens = int(kwargs.get("max_tokens") or 1024)
|
||
use_prompt_profile = bool(kwargs.get("use_prompt_profile", True))
|
||
response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete"
|
||
routing_meta = _normalize_routing_meta(kwargs.get("routing_meta"))
|
||
routing_meta["response_contract"] = "structured"
|
||
prompt_profile = {"role_name": "", "system_prompt": ""}
|
||
prompt_with_context = await self._inject_model_context_bundle_prompt(
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
prompt=prompt,
|
||
)
|
||
merged_prompt = prompt_with_context
|
||
provider = ""
|
||
model = ""
|
||
engine_meta: dict[str, Any] = {}
|
||
status = "success"
|
||
response_text = ""
|
||
provider_request_payload: Any = None
|
||
provider_response_payload: Any = None
|
||
provider_attempts: Any = None
|
||
started_at_perf = time.perf_counter()
|
||
try:
|
||
if use_prompt_profile:
|
||
prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id)
|
||
merged_prompt = self._merge_prompt_with_profile(
|
||
prompt=prompt_with_context,
|
||
prompt_profile=prompt_profile,
|
||
function_name=function_name,
|
||
)
|
||
engine = await self._resolve_engine_for_call(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
routing_meta=routing_meta,
|
||
)
|
||
engine_meta = engine
|
||
provider = str(engine.get("provider") or "")
|
||
model = str(engine.get("model") or "")
|
||
cache_key = ""
|
||
if self._structured_cache_enabled(function_name):
|
||
cache_key = self._structured_cache_key(
|
||
function_name=function_name,
|
||
model=model or "unknown",
|
||
prompt=merged_prompt,
|
||
)
|
||
cached = await self._structured_cache_get(key=cache_key)
|
||
if cached is not None:
|
||
status = "success_cache_hit"
|
||
result = StructuredExtractionResult(
|
||
payload=cached,
|
||
source_field="cache",
|
||
provider=provider,
|
||
model=model,
|
||
raw_text=json.dumps(cached, ensure_ascii=False),
|
||
validation="ok",
|
||
from_cache=True,
|
||
)
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
f"function={function_name}, structured_source=cache, validation=ok, fallback=false",
|
||
)
|
||
return result
|
||
|
||
effective_routing_meta = _merge_call_routing_meta(
|
||
caller_meta=routing_meta,
|
||
engine_meta=_read_engine_routing_meta(engine),
|
||
)
|
||
completion = await self._chat_completion_raw(
|
||
provider=provider,
|
||
prompt=prompt_with_context,
|
||
system_prompt=prompt_profile.get("system_prompt") or "",
|
||
api_key=str(engine.get("api_key") or ""),
|
||
base_url=str(engine.get("base_url") or ""),
|
||
model=model,
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
function_name=function_name,
|
||
response_mode=response_mode,
|
||
routing_meta=effective_routing_meta,
|
||
)
|
||
response_text = completion.text
|
||
provider_request_payload = completion.raw_request
|
||
provider_response_payload = completion.raw_response
|
||
provider_attempts = completion.provider_attempts
|
||
extraction = self._extract_structured_object_from_response(
|
||
function_name=function_name,
|
||
response=completion.raw_response,
|
||
fallback_text=completion.text,
|
||
provider=provider,
|
||
model=model,
|
||
)
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
(
|
||
f"function={function_name}, structured_source={extraction.source_field or 'none'}, "
|
||
f"validation={extraction.validation}, fallback=false"
|
||
),
|
||
)
|
||
if extraction.payload is None:
|
||
if self._should_attempt_model_repair(function_name):
|
||
repaired = await self._repair_structured_payload_with_model(
|
||
function_name=function_name,
|
||
provider=provider,
|
||
model=model,
|
||
api_key=str(engine.get("api_key") or ""),
|
||
base_url=str(engine.get("base_url") or ""),
|
||
system_prompt=prompt_profile.get("system_prompt") or "",
|
||
original_prompt=prompt_with_context,
|
||
extraction=extraction,
|
||
raw_response=completion.raw_response,
|
||
fallback_text=completion.text,
|
||
max_tokens=max_tokens,
|
||
routing_meta=routing_meta,
|
||
)
|
||
if isinstance(repaired, StructuredExtractionResult) and repaired.payload is not None:
|
||
extraction = repaired
|
||
response_text = repaired.raw_text or response_text
|
||
else:
|
||
raise ValueError(str(extraction.error_code or "structured_schema_invalid"))
|
||
else:
|
||
raise ValueError(str(extraction.error_code or "structured_schema_invalid"))
|
||
if extraction.payload is not None and cache_key:
|
||
await self._structured_cache_set(key=cache_key, value=extraction.payload)
|
||
status = extraction.error_code or "success"
|
||
return extraction
|
||
except Exception as exc:
|
||
provider_request_payload = provider_request_payload or getattr(exc, "provider_request_payload", None)
|
||
provider_response_payload = provider_response_payload or getattr(exc, "provider_response_payload", None)
|
||
provider_attempts = provider_attempts or getattr(exc, "provider_attempts", None)
|
||
engine_routing_meta = _read_engine_routing_meta(engine_meta)
|
||
if str(engine_routing_meta.get("planned_tier") or "").strip().lower() == "structured":
|
||
failover_result, failover_engine = await self._retry_structured_with_slot_fallback_after_primary_failure(
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
failed_engine=engine_meta,
|
||
prompt=prompt_with_context,
|
||
system_prompt=str(prompt_profile.get("system_prompt") or ""),
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
response_mode=response_mode,
|
||
error=exc,
|
||
routing_meta=routing_meta,
|
||
)
|
||
if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict):
|
||
engine_meta = failover_engine
|
||
provider = str(failover_engine.get("provider") or "")
|
||
model = str(failover_engine.get("model") or "")
|
||
response_text = failover_result.raw_text or json.dumps(failover_result.payload or {}, ensure_ascii=False)
|
||
status = failover_result.error_code or "success_structured_slot_fallback"
|
||
provider_request_payload = failover_engine.get("_provider_request_payload")
|
||
provider_response_payload = failover_engine.get("_provider_response_payload")
|
||
provider_attempts = failover_engine.get("_provider_attempts")
|
||
return failover_result
|
||
error_code = self._classify_structured_exception(exc)
|
||
status = error_code
|
||
reason_text = self._build_model_error_message(
|
||
function_name=function_name,
|
||
routing_meta=_merge_call_routing_meta(caller_meta=routing_meta, engine_meta=engine_routing_meta),
|
||
error=exc,
|
||
)
|
||
response_text = reason_text
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
"function={function_name}, structured_source=none, validation=invalid, fallback=structured_slot_exhausted".format(
|
||
function_name=function_name
|
||
),
|
||
)
|
||
raise RuntimeError(reason_text) from exc
|
||
failover_result, failover_engine = await self._retry_structured_with_high_tier_after_low_model_failure(
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
failed_engine=engine_meta,
|
||
prompt=prompt_with_context,
|
||
system_prompt=str(prompt_profile.get("system_prompt") or ""),
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
response_mode=response_mode,
|
||
error=exc,
|
||
routing_meta=routing_meta,
|
||
)
|
||
if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict):
|
||
engine_meta = failover_engine
|
||
provider = str(failover_engine.get("provider") or "")
|
||
model = str(failover_engine.get("model") or "")
|
||
response_text = failover_result.raw_text or json.dumps(failover_result.payload or {}, ensure_ascii=False)
|
||
status = failover_result.error_code or "success_failover"
|
||
provider_request_payload = failover_engine.get("_provider_request_payload")
|
||
provider_response_payload = failover_engine.get("_provider_response_payload")
|
||
provider_attempts = failover_engine.get("_provider_attempts")
|
||
return failover_result
|
||
error_code = self._classify_structured_exception(exc)
|
||
status = error_code
|
||
reason_text = self._build_model_error_message(
|
||
function_name=function_name,
|
||
routing_meta=_merge_call_routing_meta(
|
||
caller_meta=routing_meta,
|
||
engine_meta=_read_engine_routing_meta(engine_meta),
|
||
),
|
||
error=exc,
|
||
)
|
||
response_text = reason_text
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
f"function={function_name}, structured_source=none, validation=invalid, fallback=false",
|
||
)
|
||
raise RuntimeError(reason_text) from exc
|
||
finally:
|
||
try:
|
||
log_payload = response_text or status
|
||
duration_ms = int((time.perf_counter() - started_at_perf) * 1000)
|
||
call_routing_meta = _merge_call_routing_meta(
|
||
caller_meta=routing_meta,
|
||
engine_meta=_read_engine_routing_meta(engine_meta),
|
||
)
|
||
call_routing_meta["duration_ms"] = duration_ms
|
||
await self._log_ai_call(
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
prompt=merged_prompt,
|
||
response=log_payload,
|
||
status=status,
|
||
provider=provider,
|
||
model=model,
|
||
routing_meta=call_routing_meta,
|
||
provider_request_payload=provider_request_payload or engine_meta.get("_provider_request_payload"),
|
||
provider_response_payload=provider_response_payload or engine_meta.get("_provider_response_payload"),
|
||
provider_attempts=provider_attempts or engine_meta.get("_provider_attempts"),
|
||
user_id=user_id,
|
||
)
|
||
except Exception as log_exc:
|
||
_runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}")
|
||
|
||
@staticmethod
|
||
def _build_structured_schema_hint(function_name: str) -> str:
|
||
fn = str(function_name or "").strip().lower()
|
||
schema = STRUCTURED_OBJECT_SCHEMAS.get(fn)
|
||
if not isinstance(schema, dict):
|
||
return "{}"
|
||
schema_hint = {
|
||
"required_keys": list(schema.get("required_keys") or []),
|
||
"type_checks": dict(schema.get("type_checks") or {}),
|
||
"optional_type_checks": dict(schema.get("optional_type_checks") or {}),
|
||
}
|
||
return json.dumps(schema_hint, ensure_ascii=False)
|
||
|
||
@staticmethod
|
||
def _should_attempt_model_repair(function_name: str) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if fn == "pmflow_semantic_context_decision":
|
||
return _env_bool("LLM_INTENT_STRUCTURED_REPAIR_ENABLED", True)
|
||
return True
|
||
|
||
async def _repair_structured_payload_with_model(
|
||
self,
|
||
*,
|
||
function_name: str,
|
||
provider: str,
|
||
model: str,
|
||
api_key: str,
|
||
base_url: str,
|
||
system_prompt: str,
|
||
original_prompt: str,
|
||
extraction: StructuredExtractionResult,
|
||
raw_response: Any,
|
||
fallback_text: str,
|
||
max_tokens: int,
|
||
routing_meta: dict[str, Any] | None = None,
|
||
) -> Optional[StructuredExtractionResult]:
|
||
source_text = str(extraction.raw_text or "").strip()
|
||
if source_text.startswith("[") and source_text.endswith("]"):
|
||
source_text = ""
|
||
if not source_text:
|
||
source_text = str(fallback_text or "").strip()
|
||
if not source_text:
|
||
return None
|
||
schema_hint = self._build_structured_schema_hint(function_name)
|
||
repair_prompt = (
|
||
"首字符必须是 {,末字符必须是 }。\n"
|
||
"你是 JSON 修复器。把模型输出整理为满足目标 schema 的 JSON 对象。\n"
|
||
f"function_name: {function_name}\n"
|
||
f"schema_hint_json: {schema_hint}\n"
|
||
f"original_task_prompt: {str(original_prompt or '')[:2000]}\n"
|
||
f"model_output: {source_text[:2800]}\n"
|
||
"要求:\n"
|
||
"1. 只输出 JSON 对象。\n"
|
||
"2. 必须包含 required_keys。\n"
|
||
"3. 若字段缺失,填 null/空字符串/空数组,并保持类型合法。\n"
|
||
"4. 禁止输出解释、Markdown、代码块。"
|
||
)
|
||
try:
|
||
normalized_function = str(function_name or "").strip().lower()
|
||
configured_repair_tokens = int(_normalize_routing_meta(routing_meta).get("function_runtime_repair_max_tokens") or 0)
|
||
if configured_repair_tokens > 0:
|
||
repair_max_tokens = max(180, min(int(max_tokens or 0), configured_repair_tokens))
|
||
elif normalized_function == "pmflow_launch_draft_v1":
|
||
repair_max_tokens = max(1200, min(int(max_tokens or 0), 3600))
|
||
else:
|
||
repair_max_tokens = max(180, min(int(max_tokens or 0), 420))
|
||
if normalized_function == "pmflow_semantic_context_decision":
|
||
repair_max_tokens = min(repair_max_tokens, 220)
|
||
repair_completion = await self._chat_completion_raw(
|
||
provider=provider,
|
||
prompt=repair_prompt,
|
||
system_prompt=system_prompt,
|
||
api_key=api_key,
|
||
base_url=base_url,
|
||
model=model,
|
||
temperature=0.0,
|
||
max_tokens=repair_max_tokens,
|
||
function_name=function_name,
|
||
response_mode="fast",
|
||
routing_meta=_normalize_routing_meta(routing_meta),
|
||
)
|
||
except Exception as exc:
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
(
|
||
"模型结构化修复失败:"
|
||
f"function={function_name}, error={type(exc).__name__}:{str(exc)[:180]}"
|
||
),
|
||
)
|
||
return None
|
||
repair_extraction = self._extract_structured_object_from_response(
|
||
function_name=function_name,
|
||
response=repair_completion.raw_response,
|
||
fallback_text=repair_completion.text,
|
||
provider=provider,
|
||
model=model,
|
||
)
|
||
if repair_extraction.payload is None:
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
(
|
||
"模型结构化修复未产出有效 JSON:"
|
||
f"function={function_name}, source={repair_extraction.source_field or 'none'}, "
|
||
f"error={repair_extraction.error_code or 'unknown'}"
|
||
),
|
||
)
|
||
return None
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
(
|
||
f"function={function_name}, structured_source={repair_extraction.source_field or 'none'}, "
|
||
f"validation={repair_extraction.validation}, fallback=model_repair"
|
||
),
|
||
)
|
||
return repair_extraction
|