feat(api): 统一 LLM JSON 调用层 llm_json_call,按域 Schema 迁移 chat/memoir agents

This commit is contained in:
Kevin
2026-04-03 13:34:27 +08:00
parent 41518bda11
commit 43d1689e9c
28 changed files with 1006 additions and 352 deletions

View File

@@ -4,30 +4,22 @@ Phase1 批处理:一次 LLM 调用完成多段的抽取 + 章节分类(与
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Dict, List
from app.agents.memoir.prompts import get_batch_memoir_phase1_prep_prompt
from app.agents.memoir.schemas import BatchPhase1LLMOutput
from app.agents.state_schema import MemoirStateSchema
from app.agents.stage_constants import STAGE_SLOT_KEYS
from app.core.config import settings
from app.core.json_utils import extract_json_payload
from app.core.langchain_llm import invoke_json_object
from app.core.llm_call import LLMCallError, llm_json_call
from app.core.logging import get_logger
from app.features.conversation.models import Segment
logger = get_logger(__name__)
STAGE_ALLOWED_SLOTS: Dict[str, frozenset[str]] = {
"childhood": frozenset(
{"place", "people", "daily_life", "emotion", "turning_event"}
),
"education": frozenset({"school", "city", "motivation", "challenge", "change"}),
"career": frozenset({"job", "environment", "decision", "pressure", "growth"}),
"family": frozenset(
{"relationship", "conflict", "support", "responsibility", "change"}
),
"belief": frozenset({"value", "regret", "pride", "lesson"}),
k: frozenset(v) for k, v in STAGE_SLOT_KEYS.items()
}
@@ -73,32 +65,35 @@ def run_batch_phase1_prep(
slots_snapshot=_slots_snapshot(state),
segment_items=items,
)
raw = invoke_json_object(
llm,
prompt,
max_tokens=int(settings.memoir_phase1_batch_llm_max_tokens),
agent="BatchPhase1Prep.run",
)
parsed = json.loads(extract_json_payload(raw))
rows = parsed.get("segments") or []
if not isinstance(rows, list):
raise ValueError("batch phase1: segments must be a list")
try:
parsed = llm_json_call(
llm,
prompt,
BatchPhase1LLMOutput,
max_tokens=int(settings.memoir_phase1_batch_llm_max_tokens),
agent="BatchPhase1Prep.run",
)
except LLMCallError as e:
logger.warning("batch phase1 LLM 解析失败: {}", e)
raise ValueError("batch phase1: llm parse failed") from e
rows = parsed.segments
if not rows:
raise ValueError("batch phase1: segments must be a non-empty list")
by_id: Dict[str, BatchPhase1SegmentRow] = {}
for row in rows:
if not isinstance(row, dict):
continue
sid = str(row.get("id", "")).strip()
sid = str(row.id).strip()
if not sid:
continue
ds = str(row.get("detected_stage", "") or "").strip().lower()
slots_raw = row.get("slots") or {}
slots: Dict[str, str] = {}
if isinstance(slots_raw, dict):
for k, v in slots_raw.items():
if k and isinstance(k, str):
slots[k] = v if isinstance(v, str) else str(v)
cat_raw = str(row.get("chapter_category", row.get("category", "")) or "")
ds = str(row.detected_stage or "").strip().lower()
slots_raw = row.slots or {}
slots = {
k: v if isinstance(v, str) else str(v)
for k, v in slots_raw.items()
if k and isinstance(k, str)
}
cat_raw = str(row.chapter_category or "")
by_id[sid] = BatchPhase1SegmentRow(
detected_stage=ds or (state.current_stage or "childhood"),
slots=slots,

View File

@@ -14,13 +14,15 @@ from dataclasses import dataclass
from typing import Any
from app.agents.memoir.prompts import get_chapter_classification_json_prompt
from app.agents.memoir.schemas import ClassificationOutput
from app.agents.stage_constants import (
CHAPTER_CATEGORIES,
STAGE_KEYWORD_WEIGHTS,
STAGE_TO_DEFAULT_CATEGORY,
)
from app.core.config import settings
from app.core.json_utils import extract_json_payload
from app.core.langchain_llm import invoke_json_object
from app.core.llm_call import llm_json_call
from app.core.logging import get_logger
logger = get_logger(__name__)
@@ -135,13 +137,14 @@ class ClassificationAgent:
if llm:
try:
prompt = get_chapter_classification_json_prompt(text)
raw = invoke_json_object(
out = llm_json_call(
llm,
prompt,
max_tokens=256,
ClassificationOutput,
max_tokens=settings.memoir_classification_max_tokens,
agent="ClassificationAgent.classify",
)
category = _parse_category_from_llm_response(raw)
category = _normalize_llm_category(out.category)
if category == "none":
logger.info(
"event=chapter_classification_summary_fallback reason=llm_none "

View File

@@ -5,15 +5,15 @@ ExtractionAgent从用户消息中提取 5-stage 状态与 slots。
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Dict
from app.agents.memoir.prompts import get_state_extraction_prompt
from app.agents.memoir.schemas import StateExtractionOutput
from app.agents.stage_constants import normalize_chat_stage
from app.core.langchain_llm import invoke_json_object
from app.core.config import settings
from app.core.llm_call import LLMCallError, llm_json_call
from app.core.logging import get_logger
from app.core.json_utils import extract_json_payload
logger = get_logger(__name__)
@@ -57,14 +57,14 @@ class ExtractionAgent:
for k, v in (stage_slots or {}).items()
},
)
raw = invoke_json_object(
parsed = llm_json_call(
llm,
prompt,
max_tokens=1024,
StateExtractionOutput,
max_tokens=settings.memoir_extraction_max_tokens,
agent="ExtractionAgent.extract",
)
parsed = json.loads(extract_json_payload(raw))
raw_slots = parsed.get("slots", {}) or {}
raw_slots = parsed.slots or {}
extracted_slots = {
k: v if isinstance(v, str) else str(v) for k, v in raw_slots.items()
}
@@ -74,12 +74,12 @@ class ExtractionAgent:
current_stage, fallback=current_stage
)
else:
raw_detected = parsed.get("detected_stage", current_stage)
raw_detected = parsed.detected_stage or current_stage
detected_stage = normalize_chat_stage(
str(raw_detected) if raw_detected is not None else None,
fallback=current_stage,
)
except (json.JSONDecodeError, Exception) as e:
except LLMCallError as e:
logger.warning("ExtractionAgent LLM 解析失败: {}", e)
return ExtractionResult(detected_stage=detected_stage, slots=extracted_slots)

View File

@@ -6,14 +6,13 @@ FidelityCheckAgent比较「用户口述」与叙事 JSON 输出,判定是
from __future__ import annotations
import json
import re
from typing import Any
from app.agents.memoir.schemas import FidelityOutput
from app.core.config import settings
from app.core.langchain_llm import invoke_json_object
from app.core.llm_call import LLMCallError, llm_json_call
from app.core.logging import get_logger
from app.core.json_utils import extract_json_payload
logger = get_logger(__name__)
@@ -86,12 +85,8 @@ class FidelityCheckAgent:
判断:生成稿是否出现**既不在本轮口述、也不在已有正文**的具体新实体或虚构细节?
若内容可归因于上述两个来源的合理书面化整理pass=true。
**JSON 输出**:只输出一个合法 JSON 对象。
{{"pass": true, "reason": null}}
{{"pass": false, "reason": "一句话说明"}}
只输出 JSON不要其它文字。"""
输出形状示例:
{{"pass": true, "reason": null}}{{"pass": false, "reason": "一句话说明"}}"""
else:
prompt = f"""你是事实核对员。比较用户口述与模型生成的叙事。
@@ -106,28 +101,24 @@ class FidelityCheckAgent:
判断:生成稿是否出现口述中**明显没有**的具体新实体或虚构细节?
若仅为口述的书面化整理含文学性改写、情感渲染、过渡衔接pass=true。
**JSON 输出**:只输出一个合法 JSON 对象。
{{"pass": true, "reason": null}}
{{"pass": false, "reason": "一句话说明"}}
只输出 JSON不要其它文字。"""
输出形状示例:
{{"pass": true, "reason": null}}{{"pass": false, "reason": "一句话说明"}}"""
try:
raw = invoke_json_object(
out = llm_json_call(
llm,
prompt,
FidelityOutput,
max_tokens=settings.memoir_fidelity_check_max_tokens,
agent="FidelityCheckAgent.passes",
)
data = json.loads(extract_json_payload(raw))
ok = bool(data.get("pass", True))
ok = bool(out.pass_)
if not ok:
logger.warning(
"event=fidelity_check_fail reason={}",
(data.get("reason") or "")[:200],
(out.reason or "")[:200],
)
return ok
except Exception as e:
except LLMCallError as e:
logger.warning("FidelityCheckAgent 解析失败: {}", e)
if is_append or settings.memoir_fidelity_fail_open_on_parse_error:
logger.info("event=fidelity_parse_fail_open is_append={}", is_append)

View File

@@ -5,7 +5,6 @@ NarrativeAgent生成创意标题和叙事改写。
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from app.agents.stage_constants import CHAPTER_CATEGORIES
@@ -14,9 +13,11 @@ from app.agents.memoir.prompts import (
get_narrative_json_prompt,
get_narrative_merge_json_prompt,
)
from app.agents.memoir.schemas import MemoirTitleOutput
from app.core.config import settings
from app.core.langchain_llm import invoke_json_object
from app.core.llm_call import llm_json_call
from app.core.logging import get_logger
from app.core.json_utils import extract_json_payload
logger = get_logger(__name__)
@@ -44,17 +45,23 @@ class NarrativeAgent:
user_profile=user_profile,
birth_year=birth_year,
)
raw = invoke_json_object(
default_title = f"{CHAPTER_CATEGORIES.get(stage, stage)} 回忆"
def _title_fallback() -> MemoirTitleOutput:
return MemoirTitleOutput(title=default_title)
out = llm_json_call(
llm,
prompt,
max_tokens=256,
MemoirTitleOutput,
max_tokens=settings.memoir_title_max_tokens,
agent="NarrativeAgent.generate_title",
fallback_factory=_title_fallback,
)
data = json.loads(extract_json_payload(raw))
title = (data.get("title") or "").strip() if isinstance(data, dict) else ""
title = (out.title or "").strip()
if title:
return title.strip('"')
return f"{CHAPTER_CATEGORIES.get(stage, stage)} 回忆"
return default_title
except Exception as e:
logger.warning("NarrativeAgent 生成标题失败: {}", e)
return f"{CHAPTER_CATEGORIES.get(stage, stage)} 回忆"
@@ -100,7 +107,7 @@ class NarrativeAgent:
background_voice=background_voice,
occupation=occupation,
)
max_tokens = 8192
max_tokens = int(settings.memoir_narrative_merge_max_tokens)
agent_name = "NarrativeAgent.generate_narrative_merge"
else:
prompt = get_narrative_json_prompt(
@@ -113,7 +120,7 @@ class NarrativeAgent:
background_voice=background_voice,
occupation=occupation,
)
max_tokens = 4096
max_tokens = int(settings.memoir_narrative_max_tokens)
agent_name = "NarrativeAgent.generate_narrative"
return invoke_json_object(
llm,

View File

@@ -9,6 +9,7 @@ from typing import Optional
from app.agents.chat.background_voice import get_background_voice_narrative_block
from app.agents.chat.occupation_context import get_occupation_narrative_hint
from app.agents.stage_constants import STAGE_ERA_HINTS, STAGE_SLOT_KEYS
from app.features.memory.evidence_format import (
dedupe_evidence_chunk_rows,
format_evidence_chunks_for_prompt,
@@ -119,9 +120,8 @@ childhood, education, career_early, career_achievement, career_challenge, family
对话内容:
{segments_text}
**JSON 输出**`response_format=json_object`,只输出:
输出形状(仅此对象)
{{"category": "childhood|education|career_early|career_achievement|career_challenge|family|beliefs|summary|none"}}
不要其它文字。
若你返回 **none**,服务端会将本段映射到 **summary** 章节并仍写入回忆录正文(不落库丢弃)。"""
@@ -131,21 +131,13 @@ def get_state_extraction_prompt(
) -> str:
"""抽取结构化信息并判断阶段"""
slot_keys = list(stage_slots.keys())
all_stage_slots = {
"childhood": ["place", "people", "daily_life", "emotion", "turning_event"],
"education": ["school", "city", "motivation", "challenge", "change"],
"career": ["job", "environment", "decision", "pressure", "growth"],
"family": ["relationship", "conflict", "support", "responsibility", "change"],
"belief": ["value", "regret", "pride", "lesson"],
}
all_stage_slots = {k: list(v) for k, v in STAGE_SLOT_KEYS.items()}
return f"""你是回忆录访谈信息抽取助手。从用户话语中提取结构化信息,判断用户实际在谈论哪个人生阶段。
只提取口述中确有依据的片段,不得编造或推测。
你需要从用户话语中**先提炼与人生经历相关的核心内容**然后抽取结构化信息slots 仅填口述中确有依据的片段)。
**JSON 输出**:接口已启用 `response_format=json_object`,你必须只输出一个合法 JSON 对象,不要 markdown 代码块或其它文字。
系统当前跟踪的阶段:{current_stage}
该阶段可填 slots{slot_keys}
@@ -189,6 +181,10 @@ def get_batch_memoir_phase1_prep_prompt(
for sid, text in segment_items:
lines.append(f"- id={sid}\n 文本:{text}")
slot_lines = "\n".join(
f"- {st}: {', '.join(keys)}" for st, keys in STAGE_SLOT_KEYS.items()
)
return f"""你是回忆录访谈助手。下面有多段用户口述(按时间顺序),请**逐段**完成:
1信息抽取slots、detected_stage——规则与单段抽取相同
2章节分类chapter_category——规则与单段分类相同。
@@ -199,11 +195,7 @@ def get_batch_memoir_phase1_prep_prompt(
detected_stage 仅允许childhood | education | career | family | belief
slots 的 key 必须属于该 detected_stage 对应集合:
- childhood: place, people, daily_life, emotion, turning_event
- education: school, city, motivation, challenge, change
- career: job, environment, decision, pressure, growth
- family: relationship, conflict, support, responsibility, change
- belief: value, regret, pride, lesson
{slot_lines}
chapter_category 仅允许childhood | education | career_early | career_achievement | career_challenge | family | beliefs | summary | **none**
(不足以成篇的档案点/纯寒暄 → **none**;与单段分类一致。)
@@ -211,7 +203,7 @@ chapter_category 仅允许childhood | education | career_early | career_achie
逐段任务(按下列列表顺序,**segments 数组须覆盖每一行 id且顺序一致**
{chr(10).join(lines)}
**JSON 输出**:只输出一个合法 JSON 对象,不要 markdown格式:
输出 JSON 对象(无 markdown格式:
{{
"segments": [
{{
@@ -228,22 +220,10 @@ chapter_category 仅允许childhood | education | career_early | career_achie
def _build_age_hint(stage: str, birth_year: Optional[int] = None) -> str:
"""根据人生阶段和出生年份推算大致年龄区间"""
"""根据人生阶段和出生年份推算大致年龄区间`STAGE_ERA_HINTS`,仅作提示)。"""
if not birth_year:
return ""
stage_age_ranges = {
"childhood": (0, 12),
"education": (6, 22),
"career": (18, 60),
"career_early": (18, 30),
"career_achievement": (25, 55),
"career_challenge": (20, 55),
"family": (20, 60),
"belief": (30, 70),
"beliefs": (30, 70),
"summary": (50, 80),
}
age_range = stage_age_ranges.get(stage)
age_range = STAGE_ERA_HINTS.get(stage)
if not age_range:
return ""
year_start = birth_year + age_range[0]
@@ -298,9 +278,8 @@ def get_creative_title_json_prompt(
)
return (
base.rstrip()
+ "\n\n**JSON 输出**`response_format=json_object`,只输出"
+ "\n\n输出示例(仅此 JSON 对象)"
+ '\n{"title":"完整标题一行(含时间标注 · 正文格式)"}\n'
+ "不要其它文字。"
)
@@ -331,8 +310,7 @@ def get_narrative_json_prompt(
return f"""{get_narrative_editor_system_prompt(background_voice=background_voice, occupation=occupation)}
请将「本段用户口述」改写为第一人称书面叙述,并输出 **纯 JSON**,不要包含任何其他文字或 markdown 代码块
**JSON 输出**:接口已启用 `response_format=json_object`(与 DeepSeek JSON 模式一致),只输出一个合法 JSON 对象。
请将「本段用户口述」改写为第一人称书面叙述,并输出 **纯 JSON**(无 markdown 围栏)
阶段:{stage}
可用信息slots{slots}{profile_section}{time_section}
@@ -343,7 +321,7 @@ def get_narrative_json_prompt(
## 要求
1. **格式与输出**:只输出 JSON第一人称不使用 `#`、`##`、表格;`content` 仅含正文。
2. **事实与取材**(须遵守系统说明中的事实边界规则 14。只展开「本段用户口述」;若有参考摘录区,不得把摘录中的具体事实写成本轮亲历;过滤语气词与寒暄;不重复已有故事全文;本批同一主题/事件链;段落数量与长度随材料,禁止为凑字数编造。
2. **事实与取材**遵守事实边界,不补写未给出的细节。只展开「本段用户口述」;若有参考摘录区,不得把摘录中的具体事实写成本轮亲历;过滤语气词与寒暄;不重复已有故事全文;本批同一主题/事件链;段落数量与长度随材料,禁止为凑字数编造。
3. **不推断结局**:用户未明确说结果(是否录取、是否被选中等)时,不要凭常识补全为确定结论。
## 输出格式(严格 JSON
@@ -409,8 +387,6 @@ def get_narrative_merge_json_prompt(
你正在**扩写并重组**一则已有回忆录故事:必须把「已有故事」中的事实全部保留在输出中(可合并重复表述、调整语序),并融入「本段用户口述」中的新事实;按**事件发生的时间顺序**排列段落(早→晚);禁止丢弃未矛盾的旧内容。
**JSON 输出**:接口已启用 `response_format=json_object`,只输出一个合法 JSON 对象,不要 markdown 代码块。
阶段:{stage}
可用信息slots{slots}{profile_section}{time_section}
@@ -420,7 +396,7 @@ def get_narrative_merge_json_prompt(
## 要求
1. **全文输出**`paragraphs` 须为重组后的**完整故事正文**(非仅本段)。
2. **事实边界**(须遵守系统说明中的事实边界规则 14。不得新增「已有」或「本段」未出现的人名、地点、时间、对话、数字;第一人称、优雅书面语须符合上文传记作家文体说明;不用 `#`、`##`、表格。
2. **事实边界**遵守事实边界,不补写未给出的细节。不得新增「已有」或「本段」未出现的人名、地点、时间、对话、数字;第一人称、优雅书面语须符合上文传记作家文体说明;不用 `#`、`##`、表格。
3. 若本段与旧文完全重复或无新信息,可输出与旧文等价重组的正文(不得无故缩短到明显少于旧文)。
4. **不推断结局**:本段未明确结果时,不要补全落选/未通过等确定说法,除非旧文中已有同一事实。
@@ -485,8 +461,6 @@ def get_story_route_prompt(
merge_hint = story_route_merge_hint_for_category(chapter_category)
return f"""你是回忆录编辑助手。根据本批用户口述与【候选故事】决定 append_story 或 new_story。
**JSON 输出**:接口已启用 `response_format=json_object`,只输出下面 schema 的一个合法 JSON 对象,不要 markdown。
## 两层决策标准(必须先在心里过一遍)
1. **主题连续性信号**:价值观、关系模式、长期总结、同一反思维度;口述是否像在**同一主题容器**里加厚?
2. **事件切换信号**:是否出现**新人物组合、新地点、新时间段、新事件因果链**,与候选正文明显是**另一段经历**
@@ -535,8 +509,6 @@ def get_story_batch_plan_prompt(
merge_hint = story_route_merge_hint_for_category(chapter_category)
return f"""你是回忆录编辑助手。下面同一章节类别下有一批**按时间顺序**的用户口述片段(每段有 id 与文本)。
**JSON 输出**:接口已启用 `response_format=json_object`,只输出下面 schema 的一个合法 JSON 对象,不要 markdown。
## 两层决策标准(每一块都要应用)
1. **主题连续性信号**:价值观、关系模式、长期总结、同一反思维度。
2. **事件切换信号**:新人物组合、新地点、新时间段、新事件因果链。

View File

@@ -0,0 +1,53 @@
"""LLM JSON 边界契约Memoir agents"""
from __future__ import annotations
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
class ClassificationOutput(BaseModel):
category: str = ""
class MemoirTitleOutput(BaseModel):
title: str = ""
class FidelityOutput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
pass_: bool = Field(default=True, alias="pass")
reason: str | None = None
class StateExtractionOutput(BaseModel):
detected_stage: str = ""
slots: dict[str, str] = Field(default_factory=dict)
emotion: str | None = None
is_new_chapter: bool | None = None
class BatchPhase1SegmentRowOut(BaseModel):
id: str
detected_stage: str = ""
slots: dict[str, str] = Field(default_factory=dict)
chapter_category: str = Field(
default="",
validation_alias=AliasChoices("chapter_category", "category"),
)
model_config = ConfigDict(extra="ignore", populate_by_name=True)
class BatchPhase1LLMOutput(BaseModel):
segments: list[BatchPhase1SegmentRowOut]
__all__ = [
"BatchPhase1LLMOutput",
"BatchPhase1SegmentRowOut",
"ClassificationOutput",
"FidelityOutput",
"MemoirTitleOutput",
"StateExtractionOutput",
]

View File

@@ -15,7 +15,7 @@ from app.agents.memoir.prompts import (
)
from app.agents.memoir.story_route_payload import build_route_candidate_json
from app.core.config import settings
from app.core.langchain_llm import invoke_json_object
from app.core.llm_call import LLMCallError, llm_json_call
from app.core.logging import get_logger
from app.features.story.models import Story
@@ -132,23 +132,23 @@ class StoryRouteAgent:
batch_transcript=batch_transcript,
candidate_stories_json=payload,
)
try:
raw = invoke_json_object(
llm,
prompt,
max_tokens=1024,
agent="StoryRouteAgent.decide",
).strip()
data = json.loads(raw)
decision = StoryRouteDecision.model_validate(data)
except Exception as e:
logger.warning("StoryRouteAgent 解析失败: {}", e)
def _decide_fallback() -> StoryRouteDecision:
return StoryRouteDecision(
decision="new_story",
new_story_title=None,
reason="parse_error",
)
decision = llm_json_call(
llm,
prompt,
StoryRouteDecision,
max_tokens=settings.memoir_story_route_max_tokens,
agent="StoryRouteAgent.decide",
fallback_factory=_decide_fallback,
)
if decision.decision == "append_story":
tid = decision.target_story_id
if not tid or tid not in valid_story_ids:
@@ -188,15 +188,14 @@ class StoryRouteAgent:
candidate_stories_json=payload,
)
try:
raw = invoke_json_object(
plan = llm_json_call(
llm,
prompt,
max_tokens=4096,
StoryBatchPlan,
max_tokens=settings.memoir_story_batch_plan_max_tokens,
agent="StoryRouteAgent.plan_batch",
).strip()
data = json.loads(raw)
plan = StoryBatchPlan.model_validate(data)
except Exception as e:
)
except LLMCallError as e:
logger.warning("StoryRouteAgent.plan_batch 解析失败: {}", e)
return None