fabagent / agents /cause.py
hee_!J
feat(conductor): Plan-and-Execute ํŒจํ„ด - Central Planner + Tier executor (env AGENT_MODE)
3fb690f
Raw
History Blame Contribute Delete
9.3 kB
"""Tier 2 ์›์ธ ๋ถ„์„ ์—์ด์ „ํŠธ
๋‘ ๋ชจ๋“œ ์ง€์›:
1. **Autonomous (๊ธฐ๋ณธ, plan=None)**: LLM์ด tool์„ ์ž์œจ ํ˜ธ์ถœํ•˜๋Š” agent loop
- 2~3 iteration + synthesis = LLM ํ˜ธ์ถœ 3~4ํšŒ
2. **Conductor (plan ์ œ๊ณต)**: Planner๊ฐ€ ์ง€์ •ํ•œ tool ํ˜ธ์ถœ + ๋‹จ์ผ LLM synthesis
- Tool ์ง์ ‘ ์‹คํ–‰ + LLM ํ˜ธ์ถœ 1ํšŒ. ์žฌ๊ท€ยทloop ์—†์Œ
๊ฐ€์šฉ ๋„๊ตฌ: search_knowledge, lookup_incident_history, get_pm_history
"""
import json
from langsmith import traceable
from agents.llm import SUBAGENT_MODEL, client
from agents.tools import TOOLS_CAUSE, dispatch_tool
from agents.tools.equipment import ALARM_EQUIPMENT
from core.schema import Tier1, Tier2
MAX_TOOL_ITERATIONS = 4
TIER2_SCHEMA = {
"type": "object",
"properties": {
"causes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"pct": {"type": "integer"},
"evidence": {"type": "string"},
"citations": {"type": "array", "items": {"type": "string"}},
},
"required": ["name", "pct", "evidence", "citations"],
"additionalProperties": False,
},
}
},
"required": ["causes"],
"additionalProperties": False,
}
SYSTEM_PROMPT = """๋‹น์‹ ์€ ๋ฐ˜๋„์ฒด ๊ณต์ • ์›์ธ ๋ถ„์„ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
์ด์ƒ ์•Œ๋žŒ๊ณผ Tier 1 ํƒ์ง€ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›์•„ ๊ฐ€์žฅ ๊ฐ€๋Šฅ์„ฑ ๋†’์€ ์›์ธ 2~3๊ฐœ๋ฅผ ์ถ”์ •ํ•ฉ๋‹ˆ๋‹ค.
[๊ฐ€์šฉ ๋„๊ตฌ]
- search_knowledge(query): ์‚ฌ๋‚ด ์ง€์‹ ๋ฌธ์„œ(INC/FMEA/SOP/FLOW) hybrid ๊ฒ€์ƒ‰
- lookup_incident_history(symptom): ๊ณผ๊ฑฐ incident ๊ตฌ์กฐํ™” ์กฐํšŒ (์›์ธยทํ•ด๊ฒฐ์ฑ…ยทyield ํšŒ๋ณต๋ฅ )
- get_pm_history(equipment_id): ์žฅ๋น„ PM ์ด๋ ฅ (๋งˆ์ง€๋ง‰ PM ๊ฒฝ๊ณผ์ผ, overdue ์—ฌ๋ถ€)
[์ „๋žต]
- ๋„๊ตฌ๋ฅผ ์ž์œจ์ ์œผ๋กœ ์„ ํƒยทํ˜ธ์ถœํ•ด ์ถฉ๋ถ„ํ•œ ๊ทผ๊ฑฐ๋ฅผ ๋ชจ์œผ์„ธ์š” (๋ฐ˜๋ณต ํ˜ธ์ถœ ํ—ˆ์šฉ)
- ๋™์ผํ•œ ๋„๊ตฌ๋ฅผ ๋ฐ˜๋ณต ํ˜ธ์ถœํ•˜์ง€ ๋ง๊ณ , ํ•„์š”ํ•œ ์ •๋ณด๊ฐ€ ๋‹ค ๋ชจ์ด๋ฉด ํ˜ธ์ถœ์„ ๋ฉˆ์ถ”์„ธ์š”
- ๋ชจ์ธ ์ •๋ณด๊ฐ€ ์ถฉ๋ถ„ํ•˜๋ฉด ์ž์—ฐ์–ด๋กœ ๋‹ตํ•˜์ง€ ๋ง๊ณ  ๊ณง๋ฐ”๋กœ ์ข…๋ฃŒํ•ด ์ตœ์ข… ๊ตฌ์กฐํ™” ์ถœ๋ ฅ์œผ๋กœ ๋„˜์–ด๊ฐ€์„ธ์š”
[์ตœ์ข… ์‚ฐ์ถœ๋ฌผ (synthesis ๋‹จ๊ณ„์—์„œ JSON์œผ๋กœ)]
- causes: 2~3๊ฐœ. ๊ฐ ์›์ธ์€ name / pct(๊ธฐ์—ฌ๋„ %) / evidence(๊ตฌ์ฒด์  ๊ทผ๊ฑฐ) / citations(๋ฌธ์„œ ID ๋˜๋Š” incident ID)
- pct ํ•ฉ์€ 100์— ๊ฐ€๊นŒ์›Œ์•ผ ํ•จ, ๊ธฐ์—ฌ๋„ ๋†’์€ ์›์ธ๋ถ€ํ„ฐ ์ •๋ ฌ
- ๋„๊ตฌ๋กœ ์–ป์ง€ ๋ชปํ•œ ์ •๋ณด๋Š” ์ธ์šฉํ•˜์ง€ ๋งˆ์„ธ์š”"""
def _initial_user_prompt(alarm: dict, tier1: Tier1) -> str:
sensors = ", ".join(f["name"] for f in tier1["features"])
equipment_id = ALARM_EQUIPMENT.get(alarm["id"], "(๋ฏธ๋งคํ•‘)")
return f"""## ์ด์ƒ ์•Œ๋žŒ
- ๊ณต์ •: {alarm['title']}
- lot: {alarm['lot_id']}
- ์ด์ƒ ํ”ผ์ฒ˜: {alarm.get('feature')} {alarm.get('feature_arrow') or ''}
- ์•Œ๋žŒ ID: {alarm['id']}
- ์ถ”์ • ์žฅ๋น„ ID: {equipment_id}
## Tier 1 ์ด์ƒ ํƒ์ง€ ๊ฒฐ๊ณผ
- ์ด์ƒ ์ ์ˆ˜: {tier1['score']}
- ๊ธฐ์—ฌ ์„ผ์„œ(Top): {sensors}
์œ„ ์ •๋ณด๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์›์ธ์„ ๋ถ„์„ํ•ด ์ฃผ์„ธ์š”.
ํ•„์š”ํ•œ ์ปจํ…์ŠคํŠธ๋Š” ๊ฐ€์šฉ ๋„๊ตฌ๋ฅผ ํ˜ธ์ถœํ•ด ์ž์œจ์ ์œผ๋กœ ์ˆ˜์ง‘ํ•˜์„ธ์š”."""
def _assistant_msg_dict(msg) -> dict:
"""OpenAI assistant ์‘๋‹ต์„ messages ๋ฐฐ์—ด์— ๋‹ค์‹œ ๋„ฃ์„ dict๋กœ ๋ณ€ํ™˜"""
out: dict = {"role": "assistant", "content": msg.content}
if msg.tool_calls:
out["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in msg.tool_calls
]
return out
CONDUCTOR_SYSTEM_PROMPT = """๋‹น์‹ ์€ ๋ฐ˜๋„์ฒด ๊ณต์ • ์›์ธ ๋ถ„์„ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
Central Planner๊ฐ€ ์ด๋ฏธ ํ•„์š”ํ•œ ์ •๋ณด๋ฅผ ๋ชจ๋‘ ์ˆ˜์ง‘ํ•ด [์ˆ˜์ง‘๋œ ์ปจํ…์ŠคํŠธ]์— ์ •๋ฆฌํ•ด ๋‘์—ˆ์Šต๋‹ˆ๋‹ค.
๋‹น์‹ ์€ ์ถ”๊ฐ€ ๋„๊ตฌ ํ˜ธ์ถœ ์—†์ด ๊ทธ ์ปจํ…์ŠคํŠธ๋งŒ ์‚ฌ์šฉํ•ด ์›์ธ 2~3๊ฐœ๋ฅผ ์‚ฐ์ถœํ•˜์„ธ์š”.
[์ตœ์ข… ์‚ฐ์ถœ๋ฌผ]
- causes: 2~3๊ฐœ. ๊ฐ ์›์ธ์€ name / pct(๊ธฐ์—ฌ๋„ %) / evidence(๊ตฌ์ฒด์  ๊ทผ๊ฑฐ) / citations(๋ฌธ์„œ ID ๋˜๋Š” incident ID)
- pct ํ•ฉ์€ 100์— ๊ฐ€๊นŒ์›Œ์•ผ ํ•จ, ๊ธฐ์—ฌ๋„ ๋†’์€ ์›์ธ๋ถ€ํ„ฐ ์ •๋ ฌ
- ์ œ๊ณต๋˜์ง€ ์•Š์€ ์ •๋ณด๋Š” ์ธ์šฉํ•˜์ง€ ๋งˆ์„ธ์š”"""
def _execute_tier2_plan(plan_tier2: dict, trace_calls: list) -> str:
"""Planner๊ฐ€ ์ง€์ •ํ•œ tool๋“ค์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜๊ณ  ์ปจํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜"""
blocks = []
for query in plan_tier2.get("search_queries", []):
result = dispatch_tool("search_knowledge", {"query": query})
trace_calls.append({"name": "search_knowledge", "args": {"query": query}})
blocks.append(f"[search_knowledge: {query!r}]\n{result}")
for symptom in plan_tier2.get("incident_symptoms", []):
result = 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{result}")
for eq_id in plan_tier2.get("equipment_ids", []):
result = 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{result}")
return "\n\n".join(blocks) if blocks else "(planner๊ฐ€ ์ •๋ณด ์ˆ˜์ง‘ ์ง€์‹œ ์—†์Œ)"
def _run_cause_conductor(alarm: dict, tier1: Tier1, plan: dict, trace: dict | None) -> Tier2:
"""Conductor ๋ชจ๋“œ: plan๋Œ€๋กœ tool ์‹คํ–‰ + 1ํšŒ LLM synthesis"""
tool_log: list[dict] = []
knowledge = _execute_tier2_plan(plan.get("tier2", {}), tool_log)
sensors = ", ".join(f["name"] for f in tier1["features"])
user_prompt = f"""## ์ด์ƒ ์•Œ๋žŒ
- ๊ณต์ •: {alarm['title']}
- lot: {alarm['lot_id']}
- ์ด์ƒ ํ”ผ์ฒ˜: {alarm.get('feature')} {alarm.get('feature_arrow') or ''}
## Tier 1 ์ด์ƒ ํƒ์ง€ ๊ฒฐ๊ณผ
- ์ด์ƒ ์ ์ˆ˜: {tier1['score']}
- ๊ธฐ์—ฌ ์„ผ์„œ(Top): {sensors}
## ์ˆ˜์ง‘๋œ ์ปจํ…์ŠคํŠธ (Planner ์ง€์ •)
{knowledge}
์œ„ ์ปจํ…์ŠคํŠธ๋งŒ ์‚ฌ์šฉํ•ด ์›์ธ์„ ๋ถ„์„ํ•˜์„ธ์š”."""
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": "tier2", "schema": TIER2_SCHEMA, "strict": True},
},
)
result = json.loads(resp.choices[0].message.content)
if trace is not None:
trace["tool_calls"] = tool_log
trace["iterations"] = 0
trace["llm_calls"] = 1
trace["mode"] = "conductor"
return result
@traceable(name="Tier2_Cause_Agent", run_type="chain")
def run_cause(
alarm: dict,
tier1: Tier1,
trace: dict | None = None,
retry_hint: bool = False,
plan: dict | None = None,
) -> Tier2:
"""์›์ธ ๋ถ„์„ ์‹คํ–‰
plan ์ œ๊ณต ์‹œ conductor ๋ชจ๋“œ (๋‹จ์ผ LLM ํ˜ธ์ถœ, tool ์ง์ ‘ ์‹คํ–‰).
plan=None์ด๋ฉด ๊ธฐ์กด autonomous tool-calling loop ๋ชจ๋“œ.
retry_hint=True ๋ฉด autonomous ๋ชจ๋“œ์—์„œ ๋” ์ ๊ทน์ ์œผ๋กœ ๋„๊ตฌ๋ฅผ ํ˜ธ์ถœํ•˜๋„๋ก ์œ ๋„.
"""
if plan is not None:
return _run_cause_conductor(alarm, tier1, plan, trace)
user_prompt = _initial_user_prompt(alarm, tier1)
if retry_hint:
user_prompt += (
"\n\n[์žฌ์‹œ๋„ ์‹ ํ˜ธ] ์ง์ „ ๋ถ„์„์—์„œ ์ตœ์ƒ์œ„ ์›์ธ์˜ ๊ธฐ์—ฌ๋„๊ฐ€ ๋‚ฎ๊ฒŒ ์‚ฐ์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค. "
"๋„๊ตฌ๋ฅผ ๋” ์ ๊ทน์ ์œผ๋กœ(๋‹ค์–‘ํ•œ ์ฟผ๋ฆฌยท์ฆ์ƒ ํ‚ค์›Œ๋“œ๋กœ ์—ฌ๋Ÿฌ ๋ฒˆ) ํ˜ธ์ถœํ•ด ๋” ๊ฐ•ํ•œ ๊ทผ๊ฑฐ๋ฅผ ๋ชจ์œผ์„ธ์š”."
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
tool_call_log: list[dict] = []
iterations = 0
for iterations in range(1, MAX_TOOL_ITERATIONS + 1):
resp = client().chat.completions.create(
model=SUBAGENT_MODEL,
messages=messages,
tools=TOOLS_CAUSE,
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})
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
# synthesis ํ˜ธ์ถœ: tools ์—†์ด structured output ๊ฐ•์ œ
messages.append({
"role": "user",
"content": "์ˆ˜์ง‘ํ•œ ์ •๋ณด๋ฅผ ์ข…ํ•ฉํ•ด ์ตœ์ข… ์›์ธ ๋ถ„์„์„ JSON ์Šคํ‚ค๋งˆ์— ๋งž์ถฐ ์ถœ๋ ฅํ•ด ์ฃผ์„ธ์š”.",
})
final = client().chat.completions.create(
model=SUBAGENT_MODEL,
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {"name": "tier2", "schema": TIER2_SCHEMA, "strict": True},
},
)
result = json.loads(final.choices[0].message.content)
if trace is not None:
trace["tool_calls"] = tool_call_log
trace["iterations"] = iterations
trace["llm_calls"] = iterations + 1
trace["mode"] = "autonomous"
return result