fabagent / agents /response.py
hee_!J
feat(conductor): Plan-and-Execute ํŒจํ„ด - Central Planner + Tier executor (env AGENT_MODE)
3fb690f
Raw
History Blame Contribute Delete
11.2 kB
"""Tier 4 ๋Œ€์‘ ๊ถŒ๊ณ  ์—์ด์ „ํŠธ
๋‘ ๋ชจ๋“œ ์ง€์›:
1. Autonomous (plan=None): LLM์ด tool์„ ์ž์œจ ํ˜ธ์ถœํ•˜๋Š” agent loop
2. Conductor (plan ์ œ๊ณต): Planner๊ฐ€ ์ง€์ •ํ•œ tool ํ˜ธ์ถœ + ๋‹จ์ผ LLM synthesis
๊ฐ€์šฉ ๋„๊ตฌ: search_knowledge, lookup_incident_history, get_pm_history, check_pm_schedule
"""
import json
from langsmith import traceable
from agents.cause import _assistant_msg_dict
from agents.llm import SUBAGENT_MODEL, client
from agents.rag.store import load_document
from agents.tools import TOOLS_RESPONSE, dispatch_tool
from agents.tools.equipment import ALARM_EQUIPMENT
from core.schema import Tier1, Tier2, Tier3, Tier4
MAX_TOOL_ITERATIONS = 4
LLM_PART_SCHEMA = {
"type": "object",
"properties": {
"immediate": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {"type": "string"},
"meta": {"type": ["string", "null"]},
},
"required": ["text", "meta"],
"additionalProperties": False,
},
},
"longterm": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {"type": "string"},
"meta": {"type": ["string", "null"]},
},
"required": ["text", "meta"],
"additionalProperties": False,
},
},
"ref_doc_ids": {
"type": "array",
"items": {"type": "string"},
"description": "์ตœ์ข… ๊ถŒ๊ณ ์˜ ๊ทผ๊ฑฐ๋กœ ์ธ์šฉ๋œ ๋ฌธ์„œ ID (SOP/INC/FMEA/FLOW)",
},
},
"required": ["immediate", "longterm", "ref_doc_ids"],
"additionalProperties": False,
}
SYSTEM_PROMPT = """๋‹น์‹ ์€ ๋ฐ˜๋„์ฒด ๊ณต์ • ๋Œ€์‘ ๊ถŒ๊ณ  ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
์ด์ „ ๋‹จ๊ณ„(ํƒ์ง€ยท์›์ธยท์˜ํ–ฅ)์˜ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›์•„ ์‹ค์ œ fab์—์„œ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์กฐ์น˜๋ฅผ ๊ถŒ๊ณ ํ•ฉ๋‹ˆ๋‹ค.
[๊ฐ€์šฉ ๋„๊ตฌ]
- search_knowledge(query): SOP/INC/FMEA ๋ฌธ์„œ ๊ฒ€์ƒ‰ - ํ‘œ์ค€ ์ ˆ์ฐจ์™€ ๊ณผ๊ฑฐ ํ•ด๊ฒฐ์ฑ… ํ™•๋ณด
- lookup_incident_history(symptom): ๊ณผ๊ฑฐ incident์˜ ์‹ค์ œ resolution (์†Œ์š”์‹œ๊ฐ„ยทyield ํšŒ๋ณต๋ฅ  ํฌํ•จ)
- get_pm_history(equipment_id): ์žฅ๋น„ PM overdue ์—ฌ๋ถ€ - ์ฆ‰์‹œ PM ํ•„์š”์„ฑ ํŒ๋‹จ
- check_pm_schedule(equipment_id): ๊ฐ€์šฉ PM ์œˆ๋„์šฐ - immediate ์กฐ์น˜์˜ ์‹คํ–‰ ๊ฐ€๋Šฅ ์‹œ์  ํ™•๋ณด
[์ „๋žต]
- ๋„๊ตฌ๋ฅผ ์ž์œจ ํ˜ธ์ถœํ•ด ๊ทผ๊ฑฐ SOPยท๊ณผ๊ฑฐ ํ•ด๊ฒฐ์ฑ…ยทPM ๊ฐ€์šฉ์„ฑ์„ ํ™•์ธํ•˜์„ธ์š”
- ๊ถŒ๊ณ ๋Š” fab ํ˜„์žฅ์—์„œ ์ฆ‰์‹œ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ์ž‘์„ฑ (๋ชจํ˜ธํ•œ 'OO ๊ฒ€ํ† '๋ณด๋‹ค 'OO ์ฆ‰์‹œ ํˆฌ์ž…, ์˜ˆ์ƒ N์‹œ๊ฐ„')
- meta ํ•„๋“œ์—๋Š” ์‹œ๊ฐ„ยทhold ๋Œ€์ƒยทํ˜‘์กฐ ๋ถ€์„œ ๋“ฑ ์šด์˜ ์‹ ํ˜ธ๋ฅผ ๋„ฃ์œผ์„ธ์š”
[์ตœ์ข… ์‚ฐ์ถœ๋ฌผ]
- immediate: ์ฆ‰์‹œ ์กฐ์น˜ (์ˆ˜ ์‹œ๊ฐ„ ๋‚ด ์ˆ˜ํ–‰) 2~3๊ฑด
- longterm: ์ค‘์žฅ๊ธฐ ์กฐ์น˜ (์žฌ๋ฐœ ๋ฐฉ์ง€, ์ ˆ์ฐจ ๊ฐœ์ •) 1~2๊ฑด
- ref_doc_ids: ๊ถŒ๊ณ  ๊ทผ๊ฑฐ๋กœ ์‹ค์ œ ์‚ฌ์šฉํ•œ ๋ฌธ์„œ ID ๋ชฉ๋ก (๋„๊ตฌ๊ฐ€ ๋ฐ˜ํ™˜ํ•œ doc_id ๊ทธ๋Œ€๋กœ)"""
def _doc_description(doc_id: str) -> str:
text = load_document(doc_id)
if not text:
return doc_id
first_line = text.split("\n", 1)[0].lstrip("# ").strip()
for sep in (" โ€” ", " - "):
if sep in first_line:
return first_line.split(sep, 1)[1].strip()
return first_line
def _initial_user_prompt(alarm: dict, tier1: Tier1, tier2: Tier2, tier3: Tier3) -> str:
cause_lines = "\n".join(f"- {c['name']} ({c['pct']}%)" for c in tier2["causes"])
impact_lots_text = ", ".join(
f"{l['label']} {l['lots']}lot/{l['wafers']}์žฅ" for l in tier3["impact_lots"]
)
downstream_text = ", ".join(
f"{d['stage']}({d.get('delta', '')})" for d in tier3["dependencies"]
)
equipment_id = ALARM_EQUIPMENT.get(alarm["id"], "(๋ฏธ๋งคํ•‘)")
return f"""## ์ด์ƒ ์•Œ๋žŒ
- ๊ณต์ •: {alarm['title']}
- lot: {alarm['lot_id']}
- ์•Œ๋žŒ ID: {alarm['id']}
- ์ถ”์ • ์žฅ๋น„ ID: {equipment_id}
## Tier 1 ์ด์ƒ ํƒ์ง€
- ์ด์ƒ ์ ์ˆ˜: {tier1['score']}
## Tier 2 ์›์ธ (๊ธฐ์—ฌ๋„ ์ˆœ)
{cause_lines}
## Tier 3 ์˜ํ–ฅ
- ์˜ˆ์ƒ ์ˆ˜์œจ ์†์‹ค: {tier3['yield_loss']} %p
- downstream: {downstream_text}
- ์˜ํ–ฅ WIP: {impact_lots_text}
์œ„ ๋ถ„์„์„ ์ข…ํ•ฉํ•ด immediate์™€ longterm ์กฐ์น˜๋ฅผ ๊ถŒ๊ณ ํ•ด ์ฃผ์„ธ์š”.
ํ•„์š”ํ•œ SOPยท๊ณผ๊ฑฐ ํ•ด๊ฒฐ์ฑ…ยทPM ์œˆ๋„์šฐ๋Š” ๋„๊ตฌ๋ฅผ ํ˜ธ์ถœํ•ด ์ž์œจ์ ์œผ๋กœ ์ˆ˜์ง‘ํ•˜์„ธ์š”."""
CONDUCTOR_SYSTEM_PROMPT = """๋‹น์‹ ์€ ๋ฐ˜๋„์ฒด ๊ณต์ • ๋Œ€์‘ ๊ถŒ๊ณ  ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
Central Planner๊ฐ€ ์ด๋ฏธ ํ•„์š”ํ•œ ์ •๋ณด(SOPยท๊ณผ๊ฑฐ ํ•ด๊ฒฐ์ฑ…ยทPM ์œˆ๋„์šฐ)๋ฅผ ๋ชจ๋‘ ์ˆ˜์ง‘ํ•ด [์ˆ˜์ง‘๋œ ์ปจํ…์ŠคํŠธ]์— ์ •๋ฆฌํ•ด ๋‘์—ˆ์Šต๋‹ˆ๋‹ค.
๋‹น์‹ ์€ ์ถ”๊ฐ€ ๋„๊ตฌ ํ˜ธ์ถœ ์—†์ด ๊ทธ ์ปจํ…์ŠคํŠธ๋งŒ ์‚ฌ์šฉํ•ด ์‚ฐ์ถœํ•˜์„ธ์š”.
[์ตœ์ข… ์‚ฐ์ถœ๋ฌผ]
- immediate: ์ฆ‰์‹œ ์กฐ์น˜ 2~3๊ฑด (์ˆ˜ ์‹œ๊ฐ„ ๋‚ด ์‹คํ–‰ ๊ฐ€๋Šฅ, meta์— ์‹œ๊ฐ„ยทhold ๋Œ€์ƒยท๋ถ€์„œ ๋ช…์‹œ)
- longterm: ์ค‘์žฅ๊ธฐ ์กฐ์น˜ 1~2๊ฑด
- ref_doc_ids: ๊ถŒ๊ณ  ๊ทผ๊ฑฐ๋กœ ์‹ค์ œ ์‚ฌ์šฉํ•œ ๋ฌธ์„œ ID (search_knowledge๊ฐ€ ๋ฐ˜ํ™˜ํ•œ doc_id๋งŒ)"""
def _execute_tier4_plan(plan_tier4: dict, trace_calls: list) -> tuple[str, list[str]]:
"""Planner๊ฐ€ ์ง€์ •ํ•œ tool๋“ค์„ ์ง์ ‘ ํ˜ธ์ถœ, ์ปจํ…์ŠคํŠธ + ๊ฒ€์ƒ‰ doc_id ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜"""
blocks = []
found_docs: list[str] = []
for query in plan_tier4.get("search_queries", []):
r = dispatch_tool("search_knowledge", {"query": query})
trace_calls.append({"name": "search_knowledge", "args": {"query": query}})
blocks.append(f"[search_knowledge: {query!r}]\n{r}")
try:
parsed = json.loads(r)
found_docs.extend(h["doc_id"] for h in parsed.get("hits", []))
except (json.JSONDecodeError, KeyError):
pass
for symptom in plan_tier4.get("incident_symptoms", []):
r = dispatch_tool("lookup_incident_history", {"symptom": symptom})
trace_calls.append({"name": "lookup_incident_history", "args": {"symptom": symptom}})
blocks.append(f"[lookup_incident_history: {symptom!r}]\n{r}")
for eq_id in plan_tier4.get("equipment_ids", []):
r1 = dispatch_tool("get_pm_history", {"equipment_id": eq_id})
trace_calls.append({"name": "get_pm_history", "args": {"equipment_id": eq_id}})
blocks.append(f"[get_pm_history: {eq_id!r}]\n{r1}")
r2 = dispatch_tool("check_pm_schedule", {"equipment_id": eq_id})
trace_calls.append({"name": "check_pm_schedule", "args": {"equipment_id": eq_id}})
blocks.append(f"[check_pm_schedule: {eq_id!r}]\n{r2}")
return "\n\n".join(blocks) if blocks else "(planner๊ฐ€ ์ •๋ณด ์ˆ˜์ง‘ ์ง€์‹œ ์—†์Œ)", found_docs
def _run_response_conductor(
alarm: dict, tier1: Tier1, tier2: Tier2, tier3: Tier3,
plan: dict, trace: dict | None,
) -> Tier4:
tool_log: list[dict] = []
knowledge, found_docs = _execute_tier4_plan(plan.get("tier4", {}), tool_log)
cause_lines = "\n".join(f"- {c['name']} ({c['pct']}%)" for c in tier2["causes"])
impact_lots_text = ", ".join(
f"{l['label']} {l['lots']}lot/{l['wafers']}์žฅ" for l in tier3["impact_lots"]
)
user_prompt = f"""## ์ด์ƒ ์•Œ๋žŒ
- ๊ณต์ •: {alarm['title']}, lot: {alarm['lot_id']}
## Tier 1: ์ด์ƒ ์ ์ˆ˜ {tier1['score']}
## Tier 2 ์›์ธ (๊ธฐ์—ฌ๋„ ์ˆœ)
{cause_lines}
## Tier 3 ์˜ํ–ฅ
- ์˜ˆ์ƒ ์ˆ˜์œจ ์†์‹ค: {tier3['yield_loss']} %p
- ์˜ํ–ฅ WIP: {impact_lots_text}
## ์ˆ˜์ง‘๋œ ์ปจํ…์ŠคํŠธ (Planner ์ง€์ •)
{knowledge}
์œ„ ์ปจํ…์ŠคํŠธ๋งŒ ์‚ฌ์šฉํ•ด immediate, longterm, ref_doc_ids๋ฅผ ์‚ฐ์ถœํ•˜์„ธ์š”."""
resp = client().chat.completions.create(
model=SUBAGENT_MODEL,
messages=[
{"role": "system", "content": CONDUCTOR_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
response_format={
"type": "json_schema",
"json_schema": {"name": "tier4_part", "schema": LLM_PART_SCHEMA, "strict": True},
},
)
llm_out = json.loads(resp.choices[0].message.content)
ref_ids = llm_out.get("ref_doc_ids") or found_docs[:4]
refs = [{"id": d, "desc": _doc_description(d)} for d in ref_ids if d]
if trace is not None:
trace["tool_calls"] = tool_log
trace["iterations"] = 0
trace["llm_calls"] = 1
trace["mode"] = "conductor"
return {
"immediate": llm_out["immediate"],
"longterm": llm_out["longterm"],
"refs": refs,
}
@traceable(name="Tier4_Response_Agent", run_type="chain")
def run_response(
alarm: dict, tier1: Tier1, tier2: Tier2, tier3: Tier3,
trace: dict | None = None,
plan: dict | None = None,
) -> Tier4:
"""Tier 4 ๋Œ€์‘ ๊ถŒ๊ณ . plan ์ œ๊ณต ์‹œ conductor ๋ชจ๋“œ(LLM 1ํšŒ), ์•„๋‹ˆ๋ฉด autonomous loop."""
if plan is not None:
return _run_response_conductor(alarm, tier1, tier2, tier3, plan, trace)
# autonomous ๋ชจ๋“œ (LLM์ด tool์„ ์ž์œจ ํ˜ธ์ถœํ•˜๋Š” ๊ธฐ์กด loop)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": _initial_user_prompt(alarm, tier1, tier2, tier3)},
]
tool_call_log: list[dict] = []
iterations = 0
cited_doc_ids: list[str] = []
for iterations in range(1, MAX_TOOL_ITERATIONS + 1):
resp = client().chat.completions.create(
model=SUBAGENT_MODEL,
messages=messages,
tools=TOOLS_RESPONSE,
tool_choice="auto",
)
msg = resp.choices[0].message
messages.append(_assistant_msg_dict(msg))
if not msg.tool_calls:
break
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments or "{}")
result = dispatch_tool(tc.function.name, args)
tool_call_log.append({"name": tc.function.name, "args": args})
# search_knowledge๋กœ ๋ฐ›์€ doc_id ํŠธ๋ž˜ํ‚น (ref ํ›„๋ณด)
if tc.function.name == "search_knowledge":
try:
parsed = json.loads(result)
cited_doc_ids.extend(h["doc_id"] for h in parsed.get("hits", []))
except (json.JSONDecodeError, KeyError):
pass
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
messages.append({
"role": "user",
"content": "์ˆ˜์ง‘ํ•œ ์ •๋ณด๋ฅผ ์ข…ํ•ฉํ•ด immediate, longterm, ref_doc_ids๋ฅผ JSON ์Šคํ‚ค๋งˆ์— ๋งž์ถฐ ์ถœ๋ ฅํ•ด ์ฃผ์„ธ์š”.",
})
final = client().chat.completions.create(
model=SUBAGENT_MODEL,
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {"name": "tier4_part", "schema": LLM_PART_SCHEMA, "strict": True},
},
)
llm_out = json.loads(final.choices[0].message.content)
# LLM์ด ๋ช…์‹œํ•œ ref_doc_ids๋งŒ refs๋กœ ๊ตฌ์„ฑ (search๋กœ ๋ณธ ๋ชจ๋“  hits๊ฐ€ ์•„๋‹ˆ๋ผ ์‹ค์ œ ์‚ฌ์šฉํ•œ ๊ฒƒ)
ref_ids = llm_out.get("ref_doc_ids") or cited_doc_ids[:4]
refs = [{"id": d, "desc": _doc_description(d)} for d in ref_ids if d]
if trace is not None:
trace["tool_calls"] = tool_call_log
trace["iterations"] = iterations
trace["llm_calls"] = iterations + 1
trace["mode"] = "autonomous"
return {
"immediate": llm_out["immediate"],
"longterm": llm_out["longterm"],
"refs": refs,
}