outline_service.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742
  1. """目录生成服务。"""
  2. import json
  3. from typing import Any, Dict
  4. from ..models.schemas import (
  5. OutlineChildrenResponse,
  6. OutlineMode,
  7. OutlineResponse,
  8. OutlineReviewResponse,
  9. TechnicalRequirementGroupResponse,
  10. )
  11. from ..utils.openai_util import OpenAIUtil, ProgressCallback
  12. from ..utils.errors import AppError
  13. from ..utils.prompts.outline_prompts import (
  14. extract_requirement_groups_messages,
  15. generate_aligned_children_outline_prompt,
  16. generate_aligned_children_outline_with_old_prompt,
  17. generate_children_outline_prompt,
  18. generate_children_outline_with_old_prompt,
  19. generate_outline_prompt,
  20. generate_outline_with_old_prompt,
  21. generate_top_level_outline_prompt,
  22. generate_top_level_outline_with_old_prompt,
  23. review_aligned_outline_messages,
  24. review_outline_messages,
  25. )
  26. class OutlineService:
  27. """负责目录生成、审核与技术评分项对齐。"""
  28. def __init__(self, ai: OpenAIUtil | None = None):
  29. self.ai = ai or OpenAIUtil()
  30. async def generate_outline(
  31. self,
  32. overview: str,
  33. requirements: str,
  34. mode: OutlineMode = OutlineMode.FREE,
  35. uploaded_expand: bool = False,
  36. old_outline: str | None = None,
  37. progress_callback: ProgressCallback | None = None,
  38. ) -> Dict[str, Any]:
  39. """生成目录结构。"""
  40. if mode == OutlineMode.ALIGNED:
  41. return await self._generate_aligned_outline_workflow(
  42. overview=overview,
  43. requirements=requirements,
  44. uploaded_expand=uploaded_expand,
  45. old_outline=old_outline,
  46. progress_callback=progress_callback,
  47. )
  48. return await self._generate_outline_workflow(
  49. overview=overview,
  50. requirements=requirements,
  51. uploaded_expand=uploaded_expand,
  52. old_outline=old_outline,
  53. progress_callback=progress_callback,
  54. )
  55. async def _generate_outline_workflow(
  56. self,
  57. overview: str,
  58. requirements: str,
  59. uploaded_expand: bool,
  60. old_outline: str | None,
  61. progress_callback: ProgressCallback | None = None,
  62. ) -> Dict[str, Any]:
  63. """执行目录生成、审核与回退工作流。"""
  64. await self.ai.emit_progress(progress_callback, "开始生成目录结构。")
  65. first_outline, generation_mode = await self._generate_outline_by_mode(
  66. overview=overview,
  67. requirements=requirements,
  68. uploaded_expand=uploaded_expand,
  69. old_outline=old_outline,
  70. mode="auto",
  71. progress_callback=progress_callback,
  72. )
  73. await self.ai.emit_progress(
  74. progress_callback, "首次目录生成完成,开始审核目录质量。"
  75. )
  76. first_review = await self._review_outline(
  77. overview=overview,
  78. requirements=requirements,
  79. outline=first_outline,
  80. progress_callback=progress_callback,
  81. stage_label="首次审核",
  82. )
  83. if first_review["passed"]:
  84. await self.ai.emit_progress(
  85. progress_callback, "目录审核通过,准备返回结果。"
  86. )
  87. return first_outline
  88. suggestions = first_review.get("suggestions") or [
  89. "请根据项目概述和技术评分要求补全目录覆盖范围,并修正不合理章节。"
  90. ]
  91. await self.ai.emit_progress(
  92. progress_callback,
  93. "目录审核未通过,正在根据修改建议重新生成。",
  94. )
  95. try:
  96. second_outline, _ = await self._generate_outline_by_mode(
  97. overview=overview,
  98. requirements=requirements,
  99. uploaded_expand=uploaded_expand,
  100. old_outline=old_outline,
  101. mode=generation_mode,
  102. progress_callback=progress_callback,
  103. suggestions=suggestions,
  104. )
  105. except AppError:
  106. await self.ai.emit_progress(
  107. progress_callback,
  108. "根据审核建议重新生成失败,已回退到首次生成结果。",
  109. )
  110. return first_outline
  111. await self.ai.emit_progress(progress_callback, "二次生成完成,开始最终审核。")
  112. second_review = await self._review_outline(
  113. overview=overview,
  114. requirements=requirements,
  115. outline=second_outline,
  116. progress_callback=progress_callback,
  117. stage_label="最终审核",
  118. )
  119. if second_review["passed"]:
  120. await self.ai.emit_progress(
  121. progress_callback, "最终审核通过,准备返回修正后的结果。"
  122. )
  123. else:
  124. await self.ai.emit_progress(
  125. progress_callback,
  126. "最终审核未完全通过,已返回修正后的第二次结果。",
  127. )
  128. return second_outline
  129. async def _generate_aligned_outline_workflow(
  130. self,
  131. overview: str,
  132. requirements: str,
  133. uploaded_expand: bool,
  134. old_outline: str | None,
  135. progress_callback: ProgressCallback | None = None,
  136. ) -> Dict[str, Any]:
  137. """按技术评分大类一一对应生成目录。"""
  138. await self.ai.emit_progress(progress_callback, "开始提取技术评分大类。")
  139. groups = await self._extract_requirement_groups(
  140. requirements=requirements,
  141. progress_callback=progress_callback,
  142. )
  143. await self.ai.emit_progress(
  144. progress_callback, "技术评分大类提取完成,正在构建一级目录。"
  145. )
  146. first_outline = await self._generate_aligned_outline(
  147. overview=overview,
  148. requirements=requirements,
  149. groups=groups,
  150. uploaded_expand=uploaded_expand,
  151. old_outline=old_outline,
  152. progress_callback=progress_callback,
  153. )
  154. await self.ai.emit_progress(
  155. progress_callback,
  156. "目录生成完成,正在审核与技术评分项的对应关系。",
  157. )
  158. first_review = await self._review_aligned_outline(
  159. overview=overview,
  160. requirements=requirements,
  161. groups=groups,
  162. outline=first_outline,
  163. progress_callback=progress_callback,
  164. stage_label="首次审核",
  165. )
  166. if first_review["passed"]:
  167. await self.ai.emit_progress(
  168. progress_callback, "目录审核通过,准备返回结果。"
  169. )
  170. return first_outline
  171. suggestions = first_review.get("suggestions") or [
  172. "请保持一级目录与技术评分大类标题完全一致,并补全各大类下遗漏的评分细项。"
  173. ]
  174. await self.ai.emit_progress(
  175. progress_callback,
  176. "目录审核未通过,正在根据修改建议重新提取技术评分大类并重新生成目录。",
  177. )
  178. try:
  179. revised_groups = await self._extract_requirement_groups(
  180. requirements=requirements,
  181. progress_callback=progress_callback,
  182. suggestions=suggestions,
  183. )
  184. second_outline = await self._generate_aligned_outline(
  185. overview=overview,
  186. requirements=requirements,
  187. groups=revised_groups,
  188. uploaded_expand=uploaded_expand,
  189. old_outline=old_outline,
  190. progress_callback=progress_callback,
  191. suggestions=suggestions,
  192. )
  193. except AppError:
  194. await self.ai.emit_progress(
  195. progress_callback,
  196. "根据审核建议重新生成失败,已回退到首次生成结果。",
  197. )
  198. return first_outline
  199. await self.ai.emit_progress(progress_callback, "二次生成完成,开始最终审核。")
  200. second_review = await self._review_aligned_outline(
  201. overview=overview,
  202. requirements=requirements,
  203. groups=revised_groups,
  204. outline=second_outline,
  205. progress_callback=progress_callback,
  206. stage_label="最终审核",
  207. )
  208. if second_review["passed"]:
  209. await self.ai.emit_progress(
  210. progress_callback, "最终审核通过,准备返回修正后的结果。"
  211. )
  212. else:
  213. await self.ai.emit_progress(
  214. progress_callback,
  215. "最终审核未完全通过,已返回修正后的第二次结果。",
  216. )
  217. return second_outline
  218. async def _extract_requirement_groups(
  219. self,
  220. requirements: str,
  221. progress_callback: ProgressCallback | None = None,
  222. suggestions: list[str] | None = None,
  223. ) -> list[dict[str, Any]]:
  224. """提取适合作为一级目录的技术评分大类。"""
  225. response = await self.ai.collect_json_response(
  226. messages=extract_requirement_groups_messages(
  227. requirements=requirements,
  228. suggestions=suggestions,
  229. ),
  230. temperature=0.3,
  231. schema=TechnicalRequirementGroupResponse,
  232. validator=self._validate_requirement_groups,
  233. progress_callback=progress_callback,
  234. progress_label="技术评分大类",
  235. failure_message="模型返回的技术评分大类格式无效",
  236. )
  237. return response.get("groups") or []
  238. @staticmethod
  239. def _validate_requirement_groups(payload: Dict[str, Any]) -> None:
  240. """校验技术评分大类提取结果。"""
  241. groups = payload.get("groups") or []
  242. if not groups:
  243. raise ValueError("技术评分大类不能为空")
  244. requirement_ids: list[str] = []
  245. titles: list[str] = []
  246. for index, group in enumerate(groups, start=1):
  247. requirement_id = str(group.get("requirement_id") or "").strip()
  248. title = str(group.get("title") or "").strip()
  249. description = str(group.get("description") or "").strip()
  250. if not requirement_id:
  251. raise ValueError(f"第 {index} 个技术评分大类缺少 requirement_id")
  252. if not title:
  253. raise ValueError(f"第 {index} 个技术评分大类缺少标题")
  254. if not description:
  255. raise ValueError(f"第 {index} 个技术评分大类缺少描述")
  256. requirement_ids.append(requirement_id)
  257. titles.append(title)
  258. if len(set(requirement_ids)) != len(requirement_ids):
  259. raise ValueError("技术评分大类 requirement_id 不能重复")
  260. if len(set(titles)) != len(titles):
  261. raise ValueError("技术评分大类标题不能重复")
  262. @staticmethod
  263. def _build_top_level_outline_from_groups(
  264. groups: list[dict[str, Any]],
  265. ) -> list[dict[str, Any]]:
  266. """根据技术评分大类直接构造一级目录。"""
  267. outline: list[dict[str, Any]] = []
  268. for index, group in enumerate(groups, start=1):
  269. title = str(group.get("title") or "").strip()
  270. outline.append(
  271. {
  272. "id": str(index),
  273. "title": title,
  274. "description": str(group.get("description") or title).strip(),
  275. "source_requirement_id": str(
  276. group.get("requirement_id") or f"R{index}"
  277. ).strip(),
  278. "source_requirement_title": title,
  279. }
  280. )
  281. return outline
  282. @staticmethod
  283. def _validate_aligned_top_level_mapping(
  284. outline_items: list[dict[str, Any]],
  285. groups: list[dict[str, Any]],
  286. ) -> None:
  287. """校验一级目录与技术评分大类是否严格对齐。"""
  288. if len(outline_items) != len(groups):
  289. raise ValueError("一级目录数量必须与技术评分大类数量一致")
  290. for index, (item, group) in enumerate(zip(outline_items, groups), start=1):
  291. expected_title = str(group.get("title") or "").strip()
  292. actual_title = str(item.get("title") or "").strip()
  293. if actual_title != expected_title:
  294. raise ValueError(
  295. f"第 {index} 个一级目录标题必须严格等于技术评分大类标题:{expected_title}"
  296. )
  297. expected_requirement_id = str(group.get("requirement_id") or "").strip()
  298. actual_requirement_id = str(item.get("source_requirement_id") or "").strip()
  299. if actual_requirement_id != expected_requirement_id:
  300. raise ValueError(
  301. f"第 {index} 个一级目录映射的技术评分大类ID不正确:{expected_requirement_id}"
  302. )
  303. async def _generate_aligned_outline(
  304. self,
  305. overview: str,
  306. requirements: str,
  307. groups: list[dict[str, Any]],
  308. uploaded_expand: bool,
  309. old_outline: str | None,
  310. progress_callback: ProgressCallback | None,
  311. suggestions: list[str] | None = None,
  312. ) -> Dict[str, Any]:
  313. """基于技术评分大类生成严格对齐的完整目录。"""
  314. top_level_items = self._build_top_level_outline_from_groups(groups)
  315. self._validate_aligned_top_level_mapping(top_level_items, groups)
  316. assembled_items: list[dict[str, Any]] = []
  317. for index, (item, group) in enumerate(zip(top_level_items, groups), start=1):
  318. await self.ai.emit_progress(
  319. progress_callback,
  320. f"正在生成第 {index}/{len(top_level_items)} 个评分大类的二三级目录:{item.get('title', '未命名章节')}。",
  321. )
  322. merged_item = dict(item)
  323. children_response = await self._generate_outline_children_for_group(
  324. overview=overview,
  325. requirements=requirements,
  326. parent_item=item,
  327. requirement_group=group,
  328. uploaded_expand=uploaded_expand,
  329. old_outline=old_outline,
  330. suggestions=suggestions,
  331. progress_callback=progress_callback,
  332. )
  333. children = children_response.get("children") or []
  334. if children:
  335. merged_item["children"] = children
  336. assembled_items.append(merged_item)
  337. outline = self._renumber_outline({"outline": assembled_items})
  338. validated = OutlineResponse.model_validate(outline)
  339. normalized = validated.model_dump(exclude_none=True)
  340. self._validate_complete_outline(normalized)
  341. self._validate_aligned_top_level_mapping(
  342. normalized.get("outline") or [], groups
  343. )
  344. return normalized
  345. async def _generate_outline_children_for_group(
  346. self,
  347. overview: str,
  348. requirements: str,
  349. parent_item: Dict[str, Any],
  350. requirement_group: Dict[str, Any],
  351. uploaded_expand: bool,
  352. old_outline: str | None,
  353. suggestions: list[str] | None,
  354. progress_callback: ProgressCallback | None,
  355. ) -> Dict[str, Any]:
  356. """为指定技术评分大类生成二三级目录。"""
  357. if uploaded_expand:
  358. messages = generate_aligned_children_outline_with_old_prompt(
  359. overview=overview,
  360. requirements=requirements,
  361. parent_item=parent_item,
  362. requirement_group=requirement_group,
  363. old_outline=old_outline,
  364. suggestions=suggestions,
  365. )
  366. else:
  367. messages = generate_aligned_children_outline_prompt(
  368. overview=overview,
  369. requirements=requirements,
  370. parent_item=parent_item,
  371. requirement_group=requirement_group,
  372. suggestions=suggestions,
  373. )
  374. return await self.ai.collect_json_response(
  375. messages=messages,
  376. temperature=0.7,
  377. schema=OutlineChildrenResponse,
  378. validator=self._validate_children_outline,
  379. progress_callback=progress_callback,
  380. progress_label=f"章节 {parent_item.get('title', '未命名章节')} 子目录",
  381. failure_message="模型返回的目录数据格式无效",
  382. )
  383. async def _review_aligned_outline(
  384. self,
  385. overview: str,
  386. requirements: str,
  387. groups: list[dict[str, Any]],
  388. outline: Dict[str, Any],
  389. progress_callback: ProgressCallback | None,
  390. stage_label: str,
  391. ) -> Dict[str, Any]:
  392. """审核目录是否与技术评分大类一一对应。"""
  393. messages = review_aligned_outline_messages(
  394. overview=overview,
  395. requirements=requirements,
  396. groups_json=json.dumps({"groups": groups}, ensure_ascii=False),
  397. outline_json=json.dumps(outline, ensure_ascii=False),
  398. )
  399. return await self.ai.collect_json_response(
  400. messages=messages,
  401. temperature=0.3,
  402. schema=OutlineReviewResponse,
  403. progress_callback=progress_callback,
  404. progress_label=stage_label,
  405. failure_message="模型返回的审核结果格式无效",
  406. )
  407. async def _generate_outline_by_mode(
  408. self,
  409. overview: str,
  410. requirements: str,
  411. uploaded_expand: bool,
  412. old_outline: str | None,
  413. mode: str,
  414. progress_callback: ProgressCallback | None = None,
  415. suggestions: list[str] | None = None,
  416. ) -> tuple[Dict[str, Any], str]:
  417. """根据指定模式生成目录。"""
  418. if mode == "full":
  419. outline = await self._generate_outline_full(
  420. overview=overview,
  421. requirements=requirements,
  422. uploaded_expand=uploaded_expand,
  423. old_outline=old_outline,
  424. suggestions=suggestions,
  425. progress_callback=progress_callback,
  426. )
  427. return outline, "full"
  428. if mode == "fallback":
  429. outline = await self._generate_outline_fallback(
  430. overview=overview,
  431. requirements=requirements,
  432. uploaded_expand=uploaded_expand,
  433. old_outline=old_outline,
  434. suggestions=suggestions,
  435. progress_callback=progress_callback,
  436. )
  437. return outline, "fallback"
  438. try:
  439. outline = await self._generate_outline_full(
  440. overview=overview,
  441. requirements=requirements,
  442. uploaded_expand=uploaded_expand,
  443. old_outline=old_outline,
  444. suggestions=suggestions,
  445. progress_callback=progress_callback,
  446. )
  447. return outline, "full"
  448. except AppError as exc:
  449. if exc.message != "模型返回的目录数据格式无效":
  450. raise
  451. await self.ai.emit_progress(
  452. progress_callback,
  453. "一次性生成完整目录失败,切换为分步生成模式。",
  454. )
  455. outline = await self._generate_outline_fallback(
  456. overview=overview,
  457. requirements=requirements,
  458. uploaded_expand=uploaded_expand,
  459. old_outline=old_outline,
  460. suggestions=suggestions,
  461. progress_callback=progress_callback,
  462. )
  463. return outline, "fallback"
  464. async def _generate_outline_full(
  465. self,
  466. overview: str,
  467. requirements: str,
  468. uploaded_expand: bool,
  469. old_outline: str | None,
  470. suggestions: list[str] | None,
  471. progress_callback: ProgressCallback | None,
  472. ) -> Dict[str, Any]:
  473. """一次性生成完整目录。"""
  474. await self.ai.emit_progress(progress_callback, "正在一次性生成完整目录。")
  475. if uploaded_expand:
  476. messages = generate_outline_with_old_prompt(
  477. overview,
  478. requirements,
  479. old_outline,
  480. suggestions=suggestions,
  481. )
  482. else:
  483. messages = generate_outline_prompt(
  484. overview,
  485. requirements,
  486. suggestions=suggestions,
  487. )
  488. return await self.ai.collect_json_response(
  489. messages=messages,
  490. temperature=0.7,
  491. schema=OutlineResponse,
  492. validator=self._validate_complete_outline,
  493. progress_callback=progress_callback,
  494. progress_label="完整目录",
  495. failure_message="模型返回的目录数据格式无效",
  496. )
  497. async def _generate_outline_fallback(
  498. self,
  499. overview: str,
  500. requirements: str,
  501. uploaded_expand: bool,
  502. old_outline: str | None,
  503. suggestions: list[str] | None,
  504. progress_callback: ProgressCallback | None,
  505. ) -> Dict[str, Any]:
  506. """分步生成目录:先一级目录,再逐个生成二三级目录。"""
  507. await self.ai.emit_progress(
  508. progress_callback, "正在分步生成目录,先生成一级目录。"
  509. )
  510. top_level_outline = await self._generate_top_level_outline(
  511. overview=overview,
  512. requirements=requirements,
  513. uploaded_expand=uploaded_expand,
  514. old_outline=old_outline,
  515. suggestions=suggestions,
  516. progress_callback=progress_callback,
  517. )
  518. top_level_items = top_level_outline.get("outline", [])
  519. assembled_items: list[dict[str, Any]] = []
  520. for index, item in enumerate(top_level_items, start=1):
  521. await self.ai.emit_progress(
  522. progress_callback,
  523. f"正在生成第 {index}/{len(top_level_items)} 个一级目录的二三级目录:{item.get('title', '未命名章节')}。",
  524. )
  525. merged_item = {
  526. "id": item.get("id", str(index)),
  527. "title": item.get("title", "未命名章节"),
  528. "description": item.get("description", ""),
  529. }
  530. children_response = await self._generate_outline_children(
  531. overview=overview,
  532. requirements=requirements,
  533. parent_item=item,
  534. uploaded_expand=uploaded_expand,
  535. old_outline=old_outline,
  536. suggestions=suggestions,
  537. progress_callback=progress_callback,
  538. )
  539. children = children_response.get("children") or []
  540. if children:
  541. merged_item["children"] = children
  542. assembled_items.append(merged_item)
  543. outline = self._renumber_outline({"outline": assembled_items})
  544. validated = OutlineResponse.model_validate(outline)
  545. normalized = validated.model_dump(exclude_none=True)
  546. self._validate_complete_outline(normalized)
  547. return normalized
  548. async def _generate_top_level_outline(
  549. self,
  550. overview: str,
  551. requirements: str,
  552. uploaded_expand: bool,
  553. old_outline: str | None,
  554. suggestions: list[str] | None,
  555. progress_callback: ProgressCallback | None,
  556. ) -> Dict[str, Any]:
  557. """生成一级目录。"""
  558. if uploaded_expand:
  559. messages = generate_top_level_outline_with_old_prompt(
  560. overview=overview,
  561. requirements=requirements,
  562. old_outline=old_outline,
  563. suggestions=suggestions,
  564. )
  565. else:
  566. messages = generate_top_level_outline_prompt(
  567. overview=overview,
  568. requirements=requirements,
  569. suggestions=suggestions,
  570. )
  571. return await self.ai.collect_json_response(
  572. messages=messages,
  573. temperature=0.7,
  574. schema=OutlineResponse,
  575. validator=self._validate_top_level_outline,
  576. progress_callback=progress_callback,
  577. progress_label="一级目录",
  578. failure_message="模型返回的目录数据格式无效",
  579. )
  580. async def _generate_outline_children(
  581. self,
  582. overview: str,
  583. requirements: str,
  584. parent_item: Dict[str, Any],
  585. uploaded_expand: bool,
  586. old_outline: str | None,
  587. suggestions: list[str] | None,
  588. progress_callback: ProgressCallback | None,
  589. ) -> Dict[str, Any]:
  590. """生成某个一级目录下的二三级目录。"""
  591. if uploaded_expand:
  592. messages = generate_children_outline_with_old_prompt(
  593. overview=overview,
  594. requirements=requirements,
  595. parent_item=parent_item,
  596. old_outline=old_outline,
  597. suggestions=suggestions,
  598. )
  599. else:
  600. messages = generate_children_outline_prompt(
  601. overview=overview,
  602. requirements=requirements,
  603. parent_item=parent_item,
  604. suggestions=suggestions,
  605. )
  606. return await self.ai.collect_json_response(
  607. messages=messages,
  608. temperature=0.7,
  609. schema=OutlineChildrenResponse,
  610. validator=self._validate_children_outline,
  611. progress_callback=progress_callback,
  612. progress_label=f"章节 {parent_item.get('title', '未命名章节')} 子目录",
  613. failure_message="模型返回的目录数据格式无效",
  614. )
  615. async def _review_outline(
  616. self,
  617. overview: str,
  618. requirements: str,
  619. outline: Dict[str, Any],
  620. progress_callback: ProgressCallback | None,
  621. stage_label: str,
  622. ) -> Dict[str, Any]:
  623. """审核目录是否符合招标要求。"""
  624. messages = review_outline_messages(
  625. overview=overview,
  626. requirements=requirements,
  627. outline_json=json.dumps(outline, ensure_ascii=False),
  628. )
  629. return await self.ai.collect_json_response(
  630. messages=messages,
  631. temperature=0.3,
  632. schema=OutlineReviewResponse,
  633. progress_callback=progress_callback,
  634. progress_label=stage_label,
  635. failure_message="模型返回的审核结果格式无效",
  636. )
  637. @classmethod
  638. def _renumber_outline(cls, outline: Dict[str, Any]) -> Dict[str, Any]:
  639. """统一重排目录编号,避免分步生成时编号错乱。"""
  640. return {"outline": cls._renumber_items(outline.get("outline", []))}
  641. @classmethod
  642. def _renumber_items(
  643. cls,
  644. items: list[dict[str, Any]],
  645. parent_prefix: str = "",
  646. ) -> list[dict[str, Any]]:
  647. """递归重排目录项编号。"""
  648. normalized_items: list[dict[str, Any]] = []
  649. for index, item in enumerate(items, start=1):
  650. item_id = f"{parent_prefix}.{index}" if parent_prefix else str(index)
  651. normalized_item = {**item, "id": item_id}
  652. children = item.get("children") or []
  653. if children:
  654. normalized_item["children"] = cls._renumber_items(children, item_id)
  655. else:
  656. normalized_item.pop("children", None)
  657. normalized_items.append(normalized_item)
  658. return normalized_items
  659. @staticmethod
  660. def _outline_depth(items: list[dict[str, Any]]) -> int:
  661. """计算目录的最大层级深度。"""
  662. if not items:
  663. return 0
  664. return 1 + max(
  665. OutlineService._outline_depth(item.get("children") or []) for item in items
  666. )
  667. @classmethod
  668. def _validate_complete_outline(cls, payload: Dict[str, Any]) -> None:
  669. """校验完整目录至少达到三级结构。"""
  670. outline = payload.get("outline") or []
  671. if not outline:
  672. raise ValueError("目录不能为空")
  673. if cls._outline_depth(outline) < 3:
  674. raise ValueError("完整目录至少需要三级结构")
  675. @staticmethod
  676. def _validate_top_level_outline(payload: Dict[str, Any]) -> None:
  677. """校验一级目录结果非空。"""
  678. outline = payload.get("outline") or []
  679. if not outline:
  680. raise ValueError("一级目录不能为空")
  681. @classmethod
  682. def _validate_children_outline(cls, payload: Dict[str, Any]) -> None:
  683. """校验一级目录下至少生成出二级目录。"""
  684. children = payload.get("children") or []
  685. if not children:
  686. raise ValueError("二级目录不能为空")