feat(api): 统一 LLM JSON 调用层 llm_json_call,按域 Schema 迁移 chat/memoir agents
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. **事实与取材**:(须遵守系统说明中的事实边界规则 1–4)。只展开「本段用户口述」;若有参考摘录区,不得把摘录中的具体事实写成本轮亲历;过滤语气词与寒暄;不重复已有故事全文;本批同一主题/事件链;段落数量与长度随材料,禁止为凑字数编造。
|
||||
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. **事实边界**:(须遵守系统说明中的事实边界规则 1–4)。不得新增「已有」或「本段」未出现的人名、地点、时间、对话、数字;第一人称、优雅书面语须符合上文传记作家文体说明;不用 `#`、`##`、表格。
|
||||
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. **事件切换信号**:新人物组合、新地点、新时间段、新事件因果链。
|
||||
|
||||
53
api/app/agents/memoir/schemas.py
Normal file
53
api/app/agents/memoir/schemas.py
Normal 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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user