281 lines
11 KiB
Python
281 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""探索工具 server 的只读取数实现(阶段 D)。
|
||
|
||
边界合同中的工具 server 组件:给创作智能体提供只读取数能力的统一入口。
|
||
每个工具声明读什么表、什么版本语义、返回什么结构;不提供裸查询,不写库。
|
||
连接固定 `muse_db.connect(readonly=True)`(会话级只读,写语句被库直接拒)。
|
||
|
||
查询语义与 assemble-context 的三个一等取数端保持一致(按边界合同工具 server
|
||
自持实现,不跨 Skill 导入):已确认细纲、已确认文风投影、已确认范式绑定;
|
||
另加正文章节读取与实体检索两个探索工具。
|
||
|
||
CLI:`read_tools.py <工具名> '<json 参数>'`,JSON 结果输出到 stdout;
|
||
未知工具或参数非法退出码 2(合同拒绝),运行时错误退出码 1。
|
||
`--list` 输出工具登记表(名称、描述、读取表),供扩展与测试对账。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
from typing import Any, Callable, Mapping
|
||
|
||
import muse_db
|
||
|
||
# 返回规模上限:防御性裁剪,避免单次工具返回撑爆模型上下文。
|
||
MAX_TEXT_CHARS = 30000
|
||
MAX_ROWS = 50
|
||
MAX_DESC_CHARS = 500
|
||
|
||
|
||
def _payload(value: Any) -> Any:
|
||
"""规划行 payload 统一解成对象(库里可能是 jsonb 或字符串)。"""
|
||
|
||
if isinstance(value, str):
|
||
return json.loads(value)
|
||
return value
|
||
|
||
|
||
def _style_constraints(style_payload: Any) -> list[str]:
|
||
"""把文风投影成约束字符串列表(与 assemble-context 口径一致)。"""
|
||
|
||
if isinstance(style_payload, str):
|
||
text = style_payload.strip()
|
||
return [text] if text else []
|
||
if isinstance(style_payload, dict):
|
||
rules = []
|
||
for aspect, value in style_payload.items():
|
||
text = str(value).strip()
|
||
if text:
|
||
rules.append(f"{aspect}:{text}")
|
||
return rules
|
||
return []
|
||
|
||
|
||
def _require_int(args: Mapping[str, Any], name: str) -> int:
|
||
value = args.get(name)
|
||
if isinstance(value, bool) or not isinstance(value, int):
|
||
raise ValueError(f"参数 {name} 必须是整数")
|
||
return value
|
||
|
||
|
||
def read_fine_outline(conn, args: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""读指定章最新一条已确认细纲(state=confirmed,version 倒序)。"""
|
||
|
||
work_id = _require_int(args, "work_id")
|
||
target_chapter = _require_int(args, "target_chapter")
|
||
row = conn.execute(
|
||
"SELECT payload, version FROM example_planning_section WHERE work_id=%s AND "
|
||
"section_type='fine_outline' AND target_chapter=%s AND state='confirmed' AND "
|
||
"deleted=false ORDER BY version DESC LIMIT 1",
|
||
(work_id, target_chapter),
|
||
).fetchone()
|
||
if not row:
|
||
return {"found": False, "reason": f"无第{target_chapter}章已确认细纲"}
|
||
return {"found": True, "version": row[1], "payload": _payload(row[0])}
|
||
|
||
|
||
def read_style_constraints(conn, args: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""读已确认文风并投影为约束;优先独立 style 行,回退设定行 style 字段。"""
|
||
|
||
work_id = _require_int(args, "work_id")
|
||
row = conn.execute(
|
||
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
|
||
"schema_type='style' AND state='confirmed' AND deleted=false "
|
||
"ORDER BY version DESC LIMIT 1",
|
||
(work_id,),
|
||
).fetchone()
|
||
if row:
|
||
return {"constraints": _style_constraints(_payload(row[0]))}
|
||
setting = conn.execute(
|
||
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
|
||
"section_type='setting' AND state='confirmed' AND deleted=false "
|
||
"ORDER BY version DESC LIMIT 1",
|
||
(work_id,),
|
||
).fetchone()
|
||
if setting:
|
||
payload = _payload(setting[0])
|
||
if isinstance(payload, dict):
|
||
return {"constraints": _style_constraints(payload.get("style"))}
|
||
return {"constraints": []}
|
||
|
||
|
||
def read_pattern_bindings(conn, args: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""读最新一条已确认 assembly 规划行的 patternReferences;无绑定诚实返空。"""
|
||
|
||
work_id = _require_int(args, "work_id")
|
||
row = conn.execute(
|
||
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
|
||
"section_type='assembly' AND state='confirmed' AND deleted=false "
|
||
"ORDER BY version DESC LIMIT 1",
|
||
(work_id,),
|
||
).fetchone()
|
||
if not row:
|
||
return {"bindings": []}
|
||
payload = _payload(row[0])
|
||
if not isinstance(payload, dict):
|
||
return {"bindings": []}
|
||
references = payload.get("patternReferences", [])
|
||
return {"bindings": [dict(item) for item in references if isinstance(item, dict)]}
|
||
|
||
|
||
def read_chapter_text(conn, args: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""读指定章正式正文(章节行 + 正文块按序拼接);超长截断并标记。"""
|
||
|
||
work_id = _require_int(args, "work_id")
|
||
chapter_order = _require_int(args, "chapter_order")
|
||
chapter = conn.execute(
|
||
"SELECT c.id, c.order_no, c.title FROM muse_content_chapter c "
|
||
"WHERE c.work_id=%s AND c.order_no=%s AND c.deleted=false LIMIT 1",
|
||
(work_id, chapter_order),
|
||
).fetchone()
|
||
if not chapter:
|
||
return {"found": False, "reason": f"无第{chapter_order}章(work={work_id})"}
|
||
blocks = conn.execute(
|
||
"SELECT b.content_text FROM muse_content_block b "
|
||
"WHERE b.chapter_id=%s AND b.deleted=false ORDER BY b.order_no",
|
||
(chapter[0],),
|
||
).fetchall()
|
||
text = "\n".join(str(block[0] or "") for block in blocks)
|
||
truncated = len(text) > MAX_TEXT_CHARS
|
||
return {
|
||
"found": True,
|
||
"chapterOrder": chapter[1],
|
||
"title": chapter[2],
|
||
"text": text[:MAX_TEXT_CHARS],
|
||
"truncated": truncated,
|
||
"totalChars": len(text),
|
||
}
|
||
|
||
|
||
def search_entities(conn, args: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""按名称模糊检索作品实体(人物、势力、地点、力量体系等);结果有界。"""
|
||
|
||
work_id = _require_int(args, "work_id")
|
||
keyword = str(args.get("keyword") or "").strip()
|
||
if not keyword:
|
||
raise ValueError("参数 keyword 不能为空")
|
||
entity_type = args.get("entity_type")
|
||
if entity_type is not None and not isinstance(entity_type, str):
|
||
raise ValueError("参数 entity_type 必须是字符串")
|
||
query = (
|
||
"SELECT entity_type, normalized_name, description, status "
|
||
"FROM muse_knowledge_entity WHERE work_id=%s AND deleted=false "
|
||
"AND normalized_name ILIKE %s"
|
||
)
|
||
params: list[Any] = [work_id, f"%{keyword}%"]
|
||
if entity_type:
|
||
query += " AND entity_type=%s"
|
||
params.append(entity_type)
|
||
query += " ORDER BY normalized_name LIMIT %s"
|
||
params.append(MAX_ROWS)
|
||
rows = conn.execute(query, tuple(params)).fetchall()
|
||
return {
|
||
"entities": [
|
||
{
|
||
"entityType": row[0],
|
||
"name": row[1],
|
||
"description": str(row[2] or "")[:MAX_DESC_CHARS],
|
||
"status": row[3],
|
||
}
|
||
for row in rows
|
||
]
|
||
}
|
||
|
||
|
||
# 工具登记表:名称 -> (描述、读取表、参数、实现)。扩展与测试以此对账,保证单一事实源。
|
||
# args 是参数说明:{参数名: 类型},类型取 int / str;可选参数名后加 ?。
|
||
TOOL_REGISTRY: dict[str, dict[str, Any]] = {
|
||
"read_fine_outline": {
|
||
"description": "读指定作品指定章的最新一条已确认细纲(结构骨架与硬约束)。",
|
||
"tables": ("example_planning_section",),
|
||
"args": {"work_id": "int", "target_chapter": "int"},
|
||
"impl": read_fine_outline,
|
||
},
|
||
"read_style_constraints": {
|
||
"description": "读作品已确认文风并投影为约束列表。",
|
||
"tables": ("example_planning_section",),
|
||
"args": {"work_id": "int"},
|
||
"impl": read_style_constraints,
|
||
},
|
||
"read_pattern_bindings": {
|
||
"description": "读作品已确认的范式绑定(规划期选定,写作期只消费)。",
|
||
"tables": ("example_planning_section",),
|
||
"args": {"work_id": "int"},
|
||
"impl": read_pattern_bindings,
|
||
},
|
||
"read_chapter_text": {
|
||
"description": "读指定章的正式正文全文(超长截断并标记)。",
|
||
"tables": ("muse_content_chapter", "muse_content_block"),
|
||
"args": {"work_id": "int", "chapter_order": "int"},
|
||
"impl": read_chapter_text,
|
||
},
|
||
"search_entities": {
|
||
"description": "按名称模糊检索作品实体(人物、势力、地点、力量体系等)。",
|
||
"tables": ("muse_knowledge_entity",),
|
||
"args": {"work_id": "int", "keyword": "str", "entity_type?": "str"},
|
||
"impl": search_entities,
|
||
},
|
||
}
|
||
|
||
|
||
def execute_tool(name: str, args: Mapping[str, Any], connect: Callable[..., Any] | None = None) -> dict[str, Any]:
|
||
"""执行一个登记工具;只读连接,未知工具拒绝。"""
|
||
|
||
entry = TOOL_REGISTRY.get(name)
|
||
if entry is None:
|
||
raise KeyError(f"未知探索工具: {name}")
|
||
factory = connect or (lambda: muse_db.connect(readonly=True))
|
||
conn = factory()
|
||
try:
|
||
return entry["impl"](conn, args)
|
||
finally:
|
||
closer = getattr(conn, "close", None)
|
||
if callable(closer):
|
||
closer()
|
||
|
||
|
||
def main(argv: list[str]) -> int:
|
||
if argv and argv[0] == "--list":
|
||
listing = {
|
||
name: {
|
||
"description": entry["description"],
|
||
"tables": list(entry["tables"]),
|
||
"args": entry.get("args", {}),
|
||
}
|
||
for name, entry in TOOL_REGISTRY.items()
|
||
}
|
||
print(json.dumps(listing, ensure_ascii=False, indent=1))
|
||
return 0
|
||
if len(argv) != 2:
|
||
print("用法: read_tools.py <工具名> '<json 参数>' | --list", file=sys.stderr)
|
||
return 2
|
||
name, raw_args = argv
|
||
if name not in TOOL_REGISTRY:
|
||
print(f"未知探索工具: {name}", file=sys.stderr)
|
||
return 2
|
||
try:
|
||
args = json.loads(raw_args)
|
||
except json.JSONDecodeError:
|
||
print("参数不是合法 JSON", file=sys.stderr)
|
||
return 2
|
||
if not isinstance(args, dict):
|
||
print("参数必须是 JSON 对象", file=sys.stderr)
|
||
return 2
|
||
try:
|
||
result = execute_tool(name, args)
|
||
except ValueError as exc:
|
||
print(f"参数合同拒绝: {exc}", file=sys.stderr)
|
||
return 2
|
||
except Exception as exc: # 运行时失败如实上报,不静默
|
||
print(f"工具执行失败: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||
return 1
|
||
print(json.dumps(result, ensure_ascii=False))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main(sys.argv[1:]))
|
||
|
||
|
||
__all__ = ["TOOL_REGISTRY", "execute_tool", "main"]
|