fabagent / agents /impact.py
hee_!J
feat(conductor): Plan-and-Execute ํŒจํ„ด - Central Planner + Tier executor (env AGENT_MODE)
3fb690f
Raw
History Blame Contribute Delete
9.5 kB
"""Tier 3 ๊ณต์ • ๊ฐ„ ์˜ํ–ฅ ํ‰๊ฐ€ ์—์ด์ „ํŠธ
๋‘ ๋ชจ๋“œ ์ง€์›:
1. Autonomous (plan=None): LLM์ด tool์„ ์ž์œจ ํ˜ธ์ถœํ•˜๋Š” agent loop
2. Conductor (plan ์ œ๊ณต): Planner๊ฐ€ ์ง€์ •ํ•œ tool ํ˜ธ์ถœ + ๋‹จ์ผ LLM synthesis
๊ฐ€์šฉ ๋„๊ตฌ: query_wip_status, get_downstream_steps, get_yield_baseline, get_pm_history
"""
import json
from langsmith import traceable
from agents.cause import _assistant_msg_dict
from agents.llm import SUBAGENT_MODEL, client
from agents.tools import TOOLS_IMPACT, dispatch_tool
from agents.tools.equipment import ALARM_EQUIPMENT
from core.schema import Tier1, Tier2, Tier3
from data.wip import get_affected_wip
MAX_TOOL_ITERATIONS = 4
LLM_PART_SCHEMA = {
"type": "object",
"properties": {
"yield_loss": {"type": "number"},
"downstream_dependencies": {
"type": "array",
"items": {
"type": "object",
"properties": {
"stage": {"type": "string"},
"delta": {"type": "string"},
"tag": {"type": "string"},
"kind": {"type": "string", "enum": ["impacted", "minor"]},
},
"required": ["stage", "delta", "tag", "kind"],
"additionalProperties": False,
},
},
},
"required": ["yield_loss", "downstream_dependencies"],
"additionalProperties": False,
}
SYSTEM_PROMPT = """๋‹น์‹ ์€ ๋ฐ˜๋„์ฒด ๊ณต์ • ์˜ํ–ฅ ํ‰๊ฐ€ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
์ด์ƒ์ด ๋ฐœ์ƒํ•œ ๊ณต์ •์—์„œ ์ถœ๋ฐœํ•ด downstream ์˜ํ–ฅ์„ ์ •๋Ÿ‰ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
[๊ฐ€์šฉ ๋„๊ตฌ]
- query_wip_status(alarm_id): ์˜ํ–ฅ ๋ฐ›๋Š” WIP(๊ฐ€๊ณต์ค‘ยท๋Œ€๊ธฐ์ค‘) lot/wafer ์ˆ˜
- get_downstream_steps(current_stage): ํ›„๊ณต์ • ์˜์กด์„ฑ (typical_delta_pctยทseverity ํฌํ•จ)
- get_yield_baseline(process): ๊ณต์ • ์ตœ๊ทผ 30์ผ yield ๊ธฐ์ค€์„  - baseline ๋Œ€๋น„๋กœ yield_loss ์ •๋Ÿ‰ํ™”
- get_pm_history(equipment_id): ์žฅ๋น„ PM ์ด๋ ฅ - PM ๋ˆ„๋ฝ์ด ์˜ํ–ฅ์„ ์ฆํญ์‹œํ‚ค๋Š” ์š”์ธ์ธ์ง€ ํŒ๋‹จ
[์ „๋žต]
- ๋„๊ตฌ๋ฅผ ์ž์œจ ํ˜ธ์ถœํ•ด ์ •๋Ÿ‰ ๊ทผ๊ฑฐ๋ฅผ ๋ชจ์œผ์„ธ์š”
- downstream ํ›„๊ณต์ •์„ ์•Œ๋ ค๋ฉด get_downstream_steps๋ฅผ ํ˜ธ์ถœํ•ด fab ํ๋ฆ„ ํŒŒ์•…
- yield_loss๋Š” Tier 2 ์›์ธ์˜ ๊ธฐ์—ฌ๋„ยท๊ณผ๊ฑฐ incident ํšŒ๋ณต๋ฅ ยทbaseline์„ ์ข…ํ•ฉํ•ด ์ถ”์ •
- ์ถฉ๋ถ„ํ•œ ์ •๋ณด๊ฐ€ ๋ชจ์ด๋ฉด ์ข…๋ฃŒํ•ด ์ตœ์ข… ๊ตฌ์กฐํ™” ์ถœ๋ ฅ์œผ๋กœ ๋„˜์–ด๊ฐ€์„ธ์š”
[์ตœ์ข… ์‚ฐ์ถœ๋ฌผ]
- yield_loss: ๋ณธ ์ด์ƒ์œผ๋กœ ์ธํ•œ ์˜ˆ์ƒ ์ˆ˜์œจ ์†์‹ค(%p, ์†Œ์ˆ˜ ํ•œ ์ž๋ฆฌ)
- downstream_dependencies: ์˜ํ–ฅ ๋ฐ›๋Š” ํ›„๊ณต์ • (current ์ œ์™ธ) - stage / delta / tag / kind"""
def _stage_from_alarm(alarm: dict) -> str:
return alarm["title"].split()[0]
def _initial_user_prompt(alarm: dict, tier1: Tier1, tier2: Tier2) -> str:
cause_lines = "\n".join(
f"- {c['name']} ({c['pct']}%): {c['evidence'][:120]}" for c in tier2["causes"]
)
equipment_id = ALARM_EQUIPMENT.get(alarm["id"], "(๋ฏธ๋งคํ•‘)")
current_stage = _stage_from_alarm(alarm)
return f"""## ์ด์ƒ ์•Œ๋žŒ
- ๊ณต์ •: {alarm['title']} (current_stage: {current_stage})
- lot: {alarm['lot_id']}
- ์•Œ๋žŒ ID: {alarm['id']}
- ์ถ”์ • ์žฅ๋น„ ID: {equipment_id}
## Tier 1 ์ด์ƒ ํƒ์ง€
- ์ด์ƒ ์ ์ˆ˜: {tier1['score']}
## Tier 2 ์›์ธ ๋ถ„์„ (๊ธฐ์—ฌ๋„ ์ˆœ)
{cause_lines}
์œ„ ์ •๋ณด๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ yield_loss์™€ downstream_dependencies๋ฅผ ์‚ฐ์ถœํ•ด ์ฃผ์„ธ์š”.
WIPยทdownstreamยทyieldยทPM ์ปจํ…์ŠคํŠธ๋Š” ๋„๊ตฌ๋ฅผ ํ˜ธ์ถœํ•ด ์ž์œจ์ ์œผ๋กœ ์ˆ˜์ง‘ํ•˜์„ธ์š”."""
CONDUCTOR_SYSTEM_PROMPT = """๋‹น์‹ ์€ ๋ฐ˜๋„์ฒด ๊ณต์ • ์˜ํ–ฅ ํ‰๊ฐ€ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
Central Planner๊ฐ€ ์ด๋ฏธ ํ•„์š”ํ•œ ์ •๋ณด(WIPยทdownstreamยทyieldยทPM)๋ฅผ ๋ชจ๋‘ ์ˆ˜์ง‘ํ•ด [์ˆ˜์ง‘๋œ ์ปจํ…์ŠคํŠธ]์— ์ •๋ฆฌํ•ด ๋‘์—ˆ์Šต๋‹ˆ๋‹ค.
๋‹น์‹ ์€ ์ถ”๊ฐ€ ๋„๊ตฌ ํ˜ธ์ถœ ์—†์ด ๊ทธ ์ปจํ…์ŠคํŠธ๋งŒ ์‚ฌ์šฉํ•ด ์‚ฐ์ถœํ•˜์„ธ์š”.
[์ตœ์ข… ์‚ฐ์ถœ๋ฌผ]
- yield_loss: ๋ณธ ์ด์ƒ์œผ๋กœ ์ธํ•œ ์˜ˆ์ƒ ์ˆ˜์œจ ์†์‹ค(%p, ์†Œ์ˆ˜ ํ•œ ์ž๋ฆฌ)
- downstream_dependencies: ์˜ํ–ฅ ๋ฐ›๋Š” ํ›„๊ณต์ • (current ์ œ์™ธ) - stage / delta / tag / kind"""
def _execute_tier3_plan(plan_tier3: dict, trace_calls: list) -> str:
"""Planner๊ฐ€ ์ง€์ •ํ•œ tool๋“ค์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜๊ณ  ์ปจํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜"""
blocks = []
if plan_tier3.get("alarm_id"):
r = dispatch_tool("query_wip_status", {"alarm_id": plan_tier3["alarm_id"]})
trace_calls.append({"name": "query_wip_status", "args": {"alarm_id": plan_tier3["alarm_id"]}})
blocks.append(f"[query_wip_status: {plan_tier3['alarm_id']!r}]\n{r}")
for stage in plan_tier3.get("downstream_stages", []):
r = dispatch_tool("get_downstream_steps", {"current_stage": stage})
trace_calls.append({"name": "get_downstream_steps", "args": {"current_stage": stage}})
blocks.append(f"[get_downstream_steps: {stage!r}]\n{r}")
for proc in plan_tier3.get("yield_processes", []):
r = dispatch_tool("get_yield_baseline", {"process": proc})
trace_calls.append({"name": "get_yield_baseline", "args": {"process": proc}})
blocks.append(f"[get_yield_baseline: {proc!r}]\n{r}")
for eq_id in plan_tier3.get("equipment_ids", []):
r = 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{r}")
return "\n\n".join(blocks) if blocks else "(planner๊ฐ€ ์ •๋ณด ์ˆ˜์ง‘ ์ง€์‹œ ์—†์Œ)"
def _run_impact_conductor(
alarm: dict, tier1: Tier1, tier2: Tier2, plan: dict, trace: dict | None
) -> Tier3:
tool_log: list[dict] = []
knowledge = _execute_tier3_plan(plan.get("tier3", {}), tool_log)
cause_lines = "\n".join(
f"- {c['name']} ({c['pct']}%): {c['evidence'][:120]}" for c in tier2["causes"]
)
current_stage = _stage_from_alarm(alarm)
user_prompt = f"""## ์ด์ƒ ์•Œ๋žŒ
- ๊ณต์ •: {alarm['title']} (current_stage: {current_stage})
## Tier 1: ์ด์ƒ ์ ์ˆ˜ {tier1['score']}
## Tier 2 ์›์ธ ๋ถ„์„
{cause_lines}
## ์ˆ˜์ง‘๋œ ์ปจํ…์ŠคํŠธ (Planner ์ง€์ •)
{knowledge}
์œ„ ์ปจํ…์ŠคํŠธ๋งŒ ์‚ฌ์šฉํ•ด yield_loss์™€ downstream_dependencies๋ฅผ ์‚ฐ์ถœํ•˜์„ธ์š”."""
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": "tier3_part", "schema": LLM_PART_SCHEMA, "strict": True},
},
)
llm_out = json.loads(resp.choices[0].message.content)
current_dep = {
"stage": current_stage,
"delta": f"+{tier1['score']}",
"tag": "ํ˜„์žฌ",
"kind": "current",
}
if trace is not None:
trace["tool_calls"] = tool_log
trace["iterations"] = 0
trace["llm_calls"] = 1
trace["mode"] = "conductor"
return {
"yield_loss": round(float(llm_out["yield_loss"]), 1),
"dependencies": [current_dep] + llm_out["downstream_dependencies"],
"impact_lots": get_affected_wip(alarm["id"]),
}
@traceable(name="Tier3_Impact_Agent", run_type="chain")
def run_impact(
alarm: dict,
tier1: Tier1,
tier2: Tier2,
trace: dict | None = None,
plan: dict | None = None,
) -> Tier3:
"""Tier 3 ์˜ํ–ฅ ํ‰๊ฐ€. plan ์ œ๊ณต ์‹œ conductor ๋ชจ๋“œ(LLM 1ํšŒ), ์•„๋‹ˆ๋ฉด autonomous loop."""
if plan is not None:
return _run_impact_conductor(alarm, tier1, tier2, plan, trace)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": _initial_user_prompt(alarm, tier1, tier2)},
]
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_IMPACT,
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})
messages.append({
"role": "user",
"content": "์ˆ˜์ง‘ํ•œ ์ •๋ณด๋ฅผ ์ข…ํ•ฉํ•ด yield_loss์™€ downstream_dependencies๋ฅผ JSON ์Šคํ‚ค๋งˆ์— ๋งž์ถฐ ์ถœ๋ ฅํ•ด ์ฃผ์„ธ์š”.",
})
final = client().chat.completions.create(
model=SUBAGENT_MODEL,
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {"name": "tier3_part", "schema": LLM_PART_SCHEMA, "strict": True},
},
)
llm_out = json.loads(final.choices[0].message.content)
# current ํ•ญ๋ชฉ์€ ๊ฒฐ์ •๋ก ์  (Tier 1 score ๊ธฐ๋ฐ˜)
current_dep = {
"stage": _stage_from_alarm(alarm),
"delta": f"+{tier1['score']}",
"tag": "ํ˜„์žฌ",
"kind": "current",
}
if trace is not None:
trace["tool_calls"] = tool_call_log
trace["iterations"] = iterations
trace["llm_calls"] = iterations + 1
trace["mode"] = "autonomous"
return {
"yield_loss": round(float(llm_out["yield_loss"]), 1),
"dependencies": [current_dep] + llm_out["downstream_dependencies"],
"impact_lots": get_affected_wip(alarm["id"]),
}