Coverage for src/inheritance_calculator_core/agents/interview_agent.py: 0%
288 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-17 05:31 +0900
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-17 05:31 +0900
1"""相続情報収集インタビューエージェント
3AIを使って対話形式で相続情報を収集するエージェント。
4"""
5from typing import Dict, List, Optional, Any
6from datetime import date, datetime
7from enum import Enum
8import logging
9import re
11from .ollama_client import OllamaClient
12from .prompts import InheritancePrompts
13from ..models.person import Person, Gender
14from ..models.relationship import BloodType
17class InterviewState(str, Enum):
18 """インタビューの状態"""
19 INIT = "init"
20 DECEDENT_INFO = "decedent_info"
21 SPOUSE_INFO = "spouse_info"
22 CHILDREN_INFO = "children_info"
23 PARENTS_INFO = "parents_info"
24 SIBLINGS_INFO = "siblings_info"
25 SPECIAL_CASES = "special_cases"
26 CONFIRMATION = "confirmation"
27 COMPLETED = "completed"
30class InterviewAgent:
31 """
32 対話型相続情報収集エージェント
34 ユーザーと対話しながら相続に必要な情報を収集し、
35 Personオブジェクトとして構造化する。
36 """
38 def __init__(self, ollama_client: Optional[OllamaClient] = None) -> None:
39 """
40 初期化
42 Args:
43 ollama_client: Ollamaクライアント(Noneの場合は新規作成)
44 """
45 self.logger = logging.getLogger(__name__)
46 self.client = ollama_client or OllamaClient()
47 self.prompts = InheritancePrompts()
49 # 状態管理
50 self.state = InterviewState.INIT
51 self.collected_data: Dict[str, Any] = {}
52 self.conversation_history: List[Dict[str, str]] = []
54 # 収集したデータ
55 self.decedent: Optional[Person] = None
56 self.spouses: List[Person] = []
57 self.children: List[Person] = []
58 self.parents: List[Person] = []
59 self.siblings: List[Person] = []
60 self.renounced: List[Person] = []
61 self.disqualified: List[Person] = []
62 self.disinherited: List[Person] = []
63 self.sibling_blood_types: Dict[str, BloodType] = {}
64 self.retransfer_heirs_info: Dict[str, List[Person]] = {}
66 self.logger.info("InterviewAgent initialized")
68 def start_interview(self) -> str:
69 """
70 インタビューを開始
72 Returns:
73 最初の質問メッセージ
74 """
75 self.state = InterviewState.DECEDENT_INFO
76 self.logger.info("Interview started")
78 welcome_message = self.prompts.DECEDENT_INTRO + "\n\n" + self.prompts.DECEDENT_NAME
80 # 会話履歴に追加
81 self.conversation_history.append({
82 "role": "assistant",
83 "content": welcome_message
84 })
86 return welcome_message
88 def process_response(self, user_input: str) -> str:
89 """
90 ユーザーの応答を処理し、次の質問を返す
92 Args:
93 user_input: ユーザーの入力
95 Returns:
96 次の質問または確認メッセージ
97 """
98 # 会話履歴に追加
99 self.conversation_history.append({
100 "role": "user",
101 "content": user_input
102 })
104 try:
105 # 状態に応じて処理を分岐
106 if self.state == InterviewState.DECEDENT_INFO:
107 next_message = self._process_decedent_info(user_input)
108 elif self.state == InterviewState.SPOUSE_INFO:
109 next_message = self._process_spouse_info(user_input)
110 elif self.state == InterviewState.CHILDREN_INFO:
111 next_message = self._process_children_info(user_input)
112 elif self.state == InterviewState.PARENTS_INFO:
113 next_message = self._process_parents_info(user_input)
114 elif self.state == InterviewState.SIBLINGS_INFO:
115 next_message = self._process_siblings_info(user_input)
116 elif self.state == InterviewState.SPECIAL_CASES:
117 next_message = self._process_special_cases(user_input)
118 elif self.state == InterviewState.CONFIRMATION:
119 next_message = self._process_confirmation(user_input)
120 else:
121 next_message = "インタビューは完了しました。"
123 # 会話履歴に追加
124 self.conversation_history.append({
125 "role": "assistant",
126 "content": next_message
127 })
129 return next_message
131 except Exception as e:
132 self.logger.error(f"Error processing response: {e}", exc_info=True)
133 error_message = "申し訳ございません。エラーが発生しました。もう一度お答えいただけますか?"
134 self.conversation_history.append({
135 "role": "assistant",
136 "content": error_message
137 })
138 return error_message
140 def _process_decedent_info(self, user_input: str) -> str:
141 """被相続人情報を処理"""
142 # まだ名前を収集していない
143 if "decedent_name" not in self.collected_data:
144 self.collected_data["decedent_name"] = user_input.strip()
145 return self.prompts.DECEDENT_DEATH_DATE
147 # まだ死亡日を収集していない
148 if "decedent_death_date" not in self.collected_data:
149 death_date = self._parse_date(user_input)
150 if death_date:
151 self.collected_data["decedent_death_date"] = death_date
152 return self.prompts.DECEDENT_BIRTH_DATE
153 else:
154 return "申し訳ございません。日付の形式が正しくありません。\n" + self.prompts.DECEDENT_DEATH_DATE
156 # まだ生年月日を収集していない
157 if "decedent_birth_date" not in self.collected_data:
158 if user_input.strip() in ["不明", "わからない", "分からない"]:
159 birth_date = None
160 else:
161 birth_date = self._parse_date(user_input)
163 self.collected_data["decedent_birth_date"] = birth_date
165 # 被相続人オブジェクトを作成
166 self.decedent = Person(
167 name=self.collected_data["decedent_name"],
168 is_decedent=True,
169 is_alive=False,
170 death_date=self.collected_data["decedent_death_date"],
171 birth_date=birth_date
172 )
174 # 次の状態へ
175 self.state = InterviewState.SPOUSE_INFO
176 return "\n" + self.prompts.SPOUSE_QUESTION
178 return "エラーが発生しました。"
180 def _process_spouse_info(self, user_input: str) -> str:
181 """配偶者情報を処理"""
182 # 配偶者の有無を確認
183 if "has_spouse" not in self.collected_data:
184 has_spouse = self._parse_yes_no(user_input)
185 self.collected_data["has_spouse"] = has_spouse
187 if has_spouse:
188 return self.prompts.SPOUSE_INFO
189 else:
190 # 次の状態へ
191 self.state = InterviewState.CHILDREN_INFO
192 return "\n" + self.prompts.CHILDREN_QUESTION
194 # 配偶者情報を収集中
195 # LLMで構造化データ抽出
196 spouse_info = self._extract_person_info(user_input, "配偶者")
197 spouse = Person(
198 name=spouse_info.get("name", "配偶者"),
199 is_alive=spouse_info.get("is_alive", True),
200 birth_date=spouse_info.get("birth_date"),
201 gender=spouse_info.get("gender", Gender.UNKNOWN)
202 )
203 self.spouses.append(spouse)
205 # 次の状態へ
206 self.state = InterviewState.CHILDREN_INFO
207 return "\n" + self.prompts.CHILDREN_QUESTION
209 def _process_children_info(self, user_input: str) -> str:
210 """子の情報を処理"""
211 if "has_children" not in self.collected_data:
212 has_children = self._parse_yes_no(user_input)
213 self.collected_data["has_children"] = has_children
215 if has_children:
216 return self.prompts.CHILDREN_COUNT
217 else:
218 # 次の状態へ
219 self.state = InterviewState.PARENTS_INFO
220 return "\n" + self.prompts.PARENTS_QUESTION
222 # 子の人数を確認
223 if "children_count" not in self.collected_data:
224 try:
225 count = int(user_input.strip())
226 self.collected_data["children_count"] = count
227 self.collected_data["children_collected"] = 0
228 return self.prompts.format_child_info(1)
229 except ValueError:
230 return "申し訳ございません。数字でご入力ください。\n" + self.prompts.CHILDREN_COUNT
232 # 子の情報を収集中
233 collected = self.collected_data["children_collected"]
234 total = self.collected_data["children_count"]
236 # LLMで構造化データ抽出
237 child_info = self._extract_person_info(user_input, f"子{collected + 1}")
238 child = Person(
239 name=child_info.get("name", f"子{collected + 1}"),
240 is_alive=child_info.get("is_alive", True),
241 birth_date=child_info.get("birth_date"),
242 death_date=child_info.get("death_date"),
243 gender=child_info.get("gender", Gender.UNKNOWN)
244 )
245 self.children.append(child)
247 collected += 1
248 self.collected_data["children_collected"] = collected
250 if collected < total:
251 return self.prompts.format_child_info(collected + 1)
252 else:
253 # 次の状態へ
254 self.state = InterviewState.PARENTS_INFO
255 return "\n" + self.prompts.PARENTS_QUESTION
257 def _process_parents_info(self, user_input: str) -> str:
258 """直系尊属情報を処理"""
259 if "has_parents" not in self.collected_data:
260 has_parents = self._parse_yes_no(user_input)
261 self.collected_data["has_parents"] = has_parents
263 if not has_parents:
264 # 次の状態へ
265 self.state = InterviewState.SIBLINGS_INFO
266 return "\n" + self.prompts.SIBLINGS_QUESTION
268 # 親の人数を聞く
269 self.collected_data["parents"] = []
270 return "存命の直系尊属(父母、祖父母など)は何人いますか?"
272 # 親の情報を収集
273 if "parent_count" not in self.collected_data:
274 try:
275 count = int(user_input.strip())
276 self.collected_data["parent_count"] = count
277 self.collected_data["parent_collected"] = 0
279 if count == 0:
280 self.state = InterviewState.SIBLINGS_INFO
281 return "\n" + self.prompts.SIBLINGS_QUESTION
283 return f"1人目の直系尊属について教えてください。\n氏名、続柄(父、母、祖父など)、生年月日などを入力してください。"
284 except ValueError:
285 return "数字で入力してください。存命の直系尊属は何人いますか?"
287 # 親の詳細情報を収集
288 collected = self.collected_data["parent_collected"]
289 count = self.collected_data["parent_count"]
291 parent_info = self._extract_person_info(user_input, f"直系尊属{collected + 1}")
292 parent = Person(
293 name=parent_info.get("name", f"直系尊属{collected + 1}"),
294 is_alive=True, # 存命の直系尊属のみ
295 birth_date=parent_info.get("birth_date"),
296 gender=parent_info.get("gender", Gender.UNKNOWN)
297 )
298 self.collected_data["parents"].append(parent)
299 self.collected_data["parent_collected"] = collected + 1
301 if collected + 1 < count:
302 return f"{collected + 2}人目の直系尊属について教えてください。\n氏名、続柄、生年月日などを入力してください。"
304 # 全員収集完了
305 self.state = InterviewState.SIBLINGS_INFO
306 return "\n" + self.prompts.SIBLINGS_QUESTION
308 def _process_siblings_info(self, user_input: str) -> str:
309 """兄弟姉妹情報を処理"""
310 if "has_siblings" not in self.collected_data:
311 has_siblings = self._parse_yes_no(user_input)
312 self.collected_data["has_siblings"] = has_siblings
314 if not has_siblings:
315 # 次の状態へ(特殊ケース)
316 self.state = InterviewState.SPECIAL_CASES
317 self.collected_data["special_case_step"] = "renunciation"
318 return "\n" + self.prompts.RENUNCIATION_QUESTION
320 # 兄弟姉妹の人数を聞く
321 self.collected_data["siblings"] = []
322 return "存命の兄弟姉妹は何人いますか?"
324 # 兄弟姉妹の情報を収集
325 if "sibling_count" not in self.collected_data:
326 try:
327 count = int(user_input.strip())
328 self.collected_data["sibling_count"] = count
329 self.collected_data["sibling_collected"] = 0
331 if count == 0:
332 self.state = InterviewState.SPECIAL_CASES
333 self.collected_data["special_case_step"] = "renunciation"
334 return "\n" + self.prompts.RENUNCIATION_QUESTION
336 return f"1人目の兄弟姉妹について教えてください。\n氏名、続柄(兄、姉、弟、妹など)、血縁タイプ(全血・半血)、生年月日などを入力してください。"
337 except ValueError:
338 return "数字で入力してください。存命の兄弟姉妹は何人いますか?"
340 # 兄弟姉妹の詳細情報を収集
341 collected = self.collected_data["sibling_collected"]
342 count = self.collected_data["sibling_count"]
344 sibling_info = self._extract_person_info(user_input, f"兄弟姉妹{collected + 1}")
345 sibling = Person(
346 name=sibling_info.get("name", f"兄弟姉妹{collected + 1}"),
347 is_alive=True, # 存命の兄弟姉妹のみ
348 birth_date=sibling_info.get("birth_date"),
349 gender=sibling_info.get("gender", Gender.UNKNOWN)
350 )
351 self.collected_data["siblings"].append(sibling)
352 self.collected_data["sibling_collected"] = collected + 1
354 if collected + 1 < count:
355 return f"{collected + 2}人目の兄弟姉妹について教えてください。\n氏名、続柄、血縁タイプ、生年月日などを入力してください。"
357 # 全員収集完了
358 self.state = InterviewState.SPECIAL_CASES
359 self.collected_data["special_case_step"] = "renunciation"
360 return "\n" + self.prompts.RENUNCIATION_QUESTION
362 def _process_special_cases(self, user_input: str) -> str:
363 """特殊ケース(相続放棄等)を処理"""
364 step = self.collected_data.get("special_case_step", "renunciation")
366 if step == "renunciation":
367 has_renunciation = self._parse_yes_no(user_input)
368 self.collected_data["has_renunciation"] = has_renunciation
370 # 次のステップへ
371 self.collected_data["special_case_step"] = "retransfer"
372 return "\n" + self.prompts.RETRANSFER_QUESTION
374 if step == "retransfer":
375 has_retransfer = self._parse_yes_no(user_input)
376 self.collected_data["has_retransfer"] = has_retransfer
378 # 確認へ
379 self.state = InterviewState.CONFIRMATION
380 summary = self._generate_summary()
381 return "\n" + self.prompts.format_confirmation(summary)
383 return "エラーが発生しました。"
385 def _process_confirmation(self, user_input: str) -> str:
386 """確認を処理"""
387 confirmed = self._parse_yes_no(user_input)
389 if confirmed:
390 self.state = InterviewState.COMPLETED
391 return self.prompts.CALCULATION_START
392 else:
393 return "修正が必要な項目を教えてください。"
395 def _parse_yes_no(self, text: str) -> bool:
396 """はい/いいえを解析"""
397 text = text.strip().lower()
398 if text in ["はい", "yes", "y", "有", "あり", "います", "いる"]:
399 return True
400 return False
402 def _extract_person_info(self, user_input: str, role: str) -> Dict[str, Any]:
403 """
404 ユーザー入力から人物情報を抽出
406 Args:
407 user_input: ユーザーの入力テキスト
408 role: 役割(配偶者、子1、など)
410 Returns:
411 抽出した人物情報の辞書
412 """
413 # LLMを使って構造化データ抽出
414 extraction_prompt = f"""
415以下のテキストから{role}の情報を抽出してください。
416抽出する情報:
417- 氏名(name)
418- 生存状態(is_alive: true/false)
419- 生年月日(birth_date: YYYY-MM-DD形式、不明な場合はnull)
420- 死亡日(death_date: YYYY-MM-DD形式、存命または不明な場合はnull)
421- 性別(gender: male/female/other/unknown)
423テキスト: {user_input}
425以下のJSON形式で回答してください:
426{{"name": "氏名", "is_alive": true, "birth_date": "YYYY-MM-DD", "death_date": null, "gender": "unknown"}}
427"""
429 try:
430 messages = [
431 {"role": "system", "content": "あなたは相続情報を正確に抽出するアシスタントです。"},
432 {"role": "user", "content": extraction_prompt}
433 ]
435 response = self.client.chat(messages, temperature=0.1)
437 # JSON部分を抽出
438 import json
439 json_match = re.search(r'\{[^}]+\}', response)
440 if json_match:
441 extracted: Dict[str, Any] = json.loads(json_match.group())
443 # 日付をdateオブジェクトに変換
444 if extracted.get("birth_date"):
445 extracted["birth_date"] = self._parse_date(extracted["birth_date"])
446 if extracted.get("death_date"):
447 extracted["death_date"] = self._parse_date(extracted["death_date"])
449 # 性別をGenderに変換
450 if extracted.get("gender"):
451 gender_map = {
452 "male": Gender.MALE,
453 "female": Gender.FEMALE,
454 "other": Gender.OTHER,
455 "unknown": Gender.UNKNOWN
456 }
457 extracted["gender"] = gender_map.get(extracted["gender"].lower(), Gender.UNKNOWN)
459 return extracted
461 except Exception as e:
462 self.logger.warning(f"Failed to extract person info with LLM: {e}")
464 # フォールバック: 簡易パース
465 return self._simple_parse_person_info(user_input, role)
467 def _simple_parse_person_info(self, user_input: str, role: str) -> Dict[str, Any]:
468 """
469 簡易的な人物情報パース(フォールバック用)
471 Args:
472 user_input: ユーザー入力
473 role: 役割
475 Returns:
476 抽出した情報
477 """
478 lines = user_input.strip().split("\n")
479 name = lines[0] if lines else role
481 # 生存状態の推測
482 is_alive = "死亡" not in user_input and "亡くなっ" not in user_input
484 # 日付の抽出
485 birth_date = None
486 death_date = None
487 for line in lines:
488 if "生年月日" in line or "生まれ" in line:
489 birth_date = self._parse_date(line)
490 if "死亡" in line or "亡くなっ" in line:
491 death_date = self._parse_date(line)
492 is_alive = False
494 return {
495 "name": name,
496 "is_alive": is_alive,
497 "birth_date": birth_date,
498 "death_date": death_date,
499 "gender": Gender.UNKNOWN
500 }
502 def _parse_date(self, text: str) -> Optional[date]:
503 """日付文字列を解析"""
504 text = text.strip()
506 # YYYY-MM-DD形式
507 match = re.match(r'(\d{4})-(\d{1,2})-(\d{1,2})', text)
508 if match:
509 try:
510 return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
511 except ValueError:
512 pass
514 # YYYY/MM/DD形式
515 match = re.match(r'(\d{4})/(\d{1,2})/(\d{1,2})', text)
516 if match:
517 try:
518 return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
519 except ValueError:
520 pass
522 # YYYY年MM月DD日形式
523 match = re.match(r'(\d{4})年(\d{1,2})月(\d{1,2})日', text)
524 if match:
525 try:
526 return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
527 except ValueError:
528 pass
530 return None
532 def _generate_summary(self) -> str:
533 """収集した情報のサマリーを生成"""
534 lines = []
535 lines.append(f"被相続人: {self.decedent.name if self.decedent else '不明'}")
536 if self.decedent and self.decedent.death_date:
537 lines.append(f"死亡日: {self.decedent.death_date}")
539 if self.spouses:
540 lines.append(f"\n配偶者: {', '.join(s.name for s in self.spouses)}")
542 if self.children:
543 lines.append(f"\n子: {', '.join(c.name for c in self.children)}")
545 if self.parents:
546 lines.append(f"\n直系尊属: {', '.join(p.name for p in self.parents)}")
548 if self.siblings:
549 lines.append(f"\n兄弟姉妹: {', '.join(s.name for s in self.siblings)}")
551 return "\n".join(lines)
553 def get_collected_data(self) -> Dict[str, Any]:
554 """
555 収集したデータを取得
557 Returns:
558 相続計算に必要なデータ
559 """
560 return {
561 "decedent": self.decedent,
562 "spouses": self.spouses,
563 "children": self.children,
564 "parents": self.parents,
565 "siblings": self.siblings,
566 "renounced": self.renounced,
567 "disqualified": self.disqualified,
568 "disinherited": self.disinherited,
569 "sibling_blood_types": self.sibling_blood_types,
570 "retransfer_heirs_info": self.retransfer_heirs_info,
571 }
573 def is_completed(self) -> bool:
574 """インタビューが完了したか確認"""
575 return self.state == InterviewState.COMPLETED