feat: P&ID 연결 분석, LLM 에이전트 모드, KB 확장, MCP 서버 리팩토링
- P&ID: 연결 분석 API, Prefix 규칙 관리, 카테고리 분류, DXF 그래프 빌드 - LLM: 대화 요약, tool card 영구 보존, 시계열 차트(uPlot), 에이전트 모드 - KB: 청크 미리보기, Field Instrument Inference, 인증/Qdrant 클라이언트 - MCP: 서버 기능 확장, 파이프라인 수정, timeout 개선 - Frontend: P&ID UI, LLM UI, KB UI, OPC UA Write 탭 추가 - 설정: AGENTS.md, plant_context, README, opencode.json 업데이트 - 정리: 진단 체크리스트 문서 삭제
This commit is contained in:
207
experion-loop.py
Normal file
207
experion-loop.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
experion-loop.py: Hierarchical LLM Loop
|
||||
Qwen3.6-27B (Orchestrator) → Qwen3-8B (Worker)
|
||||
|
||||
Usage:
|
||||
python3 experion-loop.py "refactor ExtractTagNames to use compiled regex"
|
||||
python3 experion-loop.py "fix all build warnings in src/Core" --max-iter 20
|
||||
"""
|
||||
|
||||
import argparse, json, os, re, subprocess, sys, time, datetime
|
||||
from openai import OpenAI
|
||||
|
||||
QWEN = dict(base_url="http://localhost:8000/v1", api_key="none", model="Qwen3.6-27B-FP8")
|
||||
WORKER_MODEL = dict(base_url="http://localhost:8001/v1", api_key="none", model="Qwen3-8B-FP8")
|
||||
|
||||
client = OpenAI(**{k:v for k,v in QWEN.items() if k != "model"}, timeout=300)
|
||||
worker = OpenAI(**{k:v for k,v in WORKER_MODEL.items() if k != "model"}, timeout=120)
|
||||
|
||||
now = lambda: datetime.datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
TOOLS = [
|
||||
{"type": "function", "function": {
|
||||
"name": "read_file", "description": "Read a file",
|
||||
"parameters": {"type": "object", "properties": {"filepath": {"type": "string"}}, "required": ["filepath"]}}},
|
||||
{"type": "function", "function": {
|
||||
"name": "write_file", "description": "Write file (overwrites)",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"filepath": {"type": "string"}, "content": {"type": "string"}}, "required": ["filepath", "content"]}}},
|
||||
{"type": "function", "function": {
|
||||
"name": "edit_file", "description": "Replace exact string in file",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"filepath": {"type": "string"}, "old_string": {"type": "string"}, "new_string": {"type": "string"}},
|
||||
"required": ["filepath", "old_string", "new_string"]}}},
|
||||
{"type": "function", "function": {
|
||||
"name": "bash", "description": "Run bash command",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"command": {"type": "string"}, "workdir": {"type": "string"}},
|
||||
"required": ["command"]}}},
|
||||
]
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(f"[{now()}] {msg}", flush=True)
|
||||
|
||||
|
||||
def call_llm(client, model: str, msgs: list, tools=None, max_tok=4096, temp=0.3) -> object:
|
||||
kwargs = dict(model=model, messages=msgs, max_tokens=max_tok, temperature=temp)
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
t0 = time.time()
|
||||
r = client.chat.completions.create(**kwargs)
|
||||
log(f"LLM {model.split('/')[-1][:12]}… {time.time()-t0:.0f}s tok={r.usage.total_tokens}")
|
||||
msg = r.choices[0].message
|
||||
if msg.content is None and msg.tool_calls:
|
||||
# tool call response - don't set content
|
||||
pass
|
||||
elif msg.content is None:
|
||||
# reasoning consumed all tokens; retry with higher limit
|
||||
log(" ↳ content was None (thinking overflow); retrying with more tokens")
|
||||
kwargs["max_tokens"] = max_tok * 2
|
||||
t0 = time.time()
|
||||
r = client.chat.completions.create(**kwargs)
|
||||
log(f" ↳ retry: {time.time()-t0:.0f}s")
|
||||
msg = r.choices[0].message
|
||||
return msg
|
||||
|
||||
|
||||
def exec_tool(name: str, args: dict, workdir: str = ".") -> str:
|
||||
try:
|
||||
if name == "read_file":
|
||||
with open(args["filepath"]) as f:
|
||||
return f.read()
|
||||
elif name == "write_file":
|
||||
os.makedirs(os.path.dirname(args["filepath"]) or ".", exist_ok=True)
|
||||
with open(args["filepath"], "w") as f:
|
||||
f.write(args["content"])
|
||||
return f"Written {len(args['content'])}b → {args['filepath']}"
|
||||
elif name == "edit_file":
|
||||
fp = args["filepath"]; old, new = args["old_string"], args["new_string"]
|
||||
with open(fp) as f:
|
||||
c = f.read()
|
||||
if old not in c:
|
||||
return f"Error: 'old_string' not found in {fp}"
|
||||
with open(fp, "w") as f:
|
||||
f.write(c.replace(old, new, 1))
|
||||
return f"Edited {fp}"
|
||||
elif name == "bash":
|
||||
res = subprocess.run(args["command"], shell=True, capture_output=True, text=True,
|
||||
cwd=args.get("workdir", workdir), timeout=120)
|
||||
out = res.stdout[-4000:] if len(res.stdout) > 4000 else res.stdout
|
||||
err = res.stderr[-2000:] if len(res.stderr) > 2000 else res.stderr
|
||||
return (out + ("\n[stderr]\n" + err if err else "")).strip() or "(empty)"
|
||||
return f"Unknown tool: {name}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def worker_loop(task: str, workdir: str, max_iter: int = 15) -> dict:
|
||||
"""Worker loop: repeat until <<DONE>> or max_iter."""
|
||||
log("┌─ Worker starting ──────────────────────────────")
|
||||
msgs = [{"role": "system", "content": (
|
||||
f"You are a coding assistant. Work in: {workdir}\n\nTask:\n{task}\n\n"
|
||||
f"Tools: read_file, write_file, edit_file, bash\n"
|
||||
f"\n## Protocol (follow strictly):\n"
|
||||
f"1. read_file ONCE (first iter only) to understand current code\n"
|
||||
f"2. Then use edit_file for ALL targeted changes (NEVER write_file for existing files)\n"
|
||||
f"3. Verify with bash (build, test, or check)\n"
|
||||
f"4. If verify fails → fix with edit_file → reverify\n"
|
||||
f"5. Output <<DONE>> when all criteria met and changes verified\n"
|
||||
f"\n## Critical rules:\n"
|
||||
f"- read_file: STRICTLY first iteration only. NEVER read the same file twice\n"
|
||||
f" If you need to re-check file content after iteration 1, use 'head' or 'grep' via bash instead\n"
|
||||
f"- edit_file: preferred for ALL changes to existing files. NEVER rewrite entire files\n"
|
||||
f"- write_file: ONLY for NEW files that don't exist yet\n"
|
||||
f"- Make as few tool calls as possible (batch changes)\n"
|
||||
f"- Always verify after changes")}]
|
||||
|
||||
for i in range(max_iter):
|
||||
log(f"├─ Worker iter {i+1}/{max_iter}")
|
||||
try:
|
||||
msg = call_llm(worker, WORKER_MODEL["model"], msgs, tools=TOOLS, max_tok=8192, temp=0.2)
|
||||
except Exception as e:
|
||||
log(f"│ ✗ API error: {e}")
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
if msg.content and "<<DONE>>" in msg.content:
|
||||
log(f"└─ ✓ Worker done ({i+1} iters)")
|
||||
return {"status": "done", "iterations": i + 1}
|
||||
|
||||
if msg.tool_calls:
|
||||
msgs.append(msg)
|
||||
for tc in msg.tool_calls:
|
||||
args = json.loads(tc.function.arguments)
|
||||
log(f"│ → {tc.function.name} {str(args)[:80]}")
|
||||
result = exec_tool(tc.function.name, args, workdir)
|
||||
# If this is a read_file, only keep first 1000 chars to avoid context bloat
|
||||
truncated = result[:1000] if tc.function.name == "read_file" else result[:5000]
|
||||
msgs.append({"role": "tool", "tool_call_id": tc.id, "content": truncated})
|
||||
elif msg.content:
|
||||
msgs.append(msg)
|
||||
msgs.append({"role": "user", "content": "Continue. Make changes and output <<DONE>> when done."})
|
||||
|
||||
log("└─ ✗ Max iterations reached")
|
||||
return {"status": "max_iter", "iterations": max_iter}
|
||||
|
||||
|
||||
def orchestrator(goal: str, workdir: str = "."):
|
||||
"""Qwen plans → Qwen executes → Qwen verifies."""
|
||||
log("═══ Qwen: planning ═══")
|
||||
msg = call_llm(client, QWEN["model"], [{"role": "system", "content": "You are a planning assistant. Answer concisely in Korean."},
|
||||
{"role": "user", "content": (
|
||||
f"Goal: {goal}\n\n"
|
||||
f"Create a numbered todo list (max 5 items).\n"
|
||||
f"Each todo: files to modify, what to change.\n"
|
||||
f"Output as markdown numbered list ONLY, no explanation.\n\n"
|
||||
f"Project: ExperionCrawler (.NET 8, C#)\n"
|
||||
f" src/Core/, src/Infrastructure/, src/Web/\n"
|
||||
f" Tests: `dotnet test`")}], max_tok=2048)
|
||||
|
||||
plan = msg.content or "(empty plan)"
|
||||
print(f"\n{plan}\n")
|
||||
|
||||
todos = [re.sub(r'^\d+[\.\)]\s*', '', l).strip()
|
||||
for l in plan.split("\n") if re.match(r'^\d+[\.\)]\s', l)]
|
||||
if not todos:
|
||||
log("No todos extracted — running full goal as one worker task")
|
||||
worker_loop(goal, workdir)
|
||||
return
|
||||
|
||||
log(f"═══ Executing {len(todos)} todos ═══")
|
||||
prev_results = []
|
||||
for idx, todo in enumerate(todos, 1):
|
||||
log(f"── Todo {idx}/{len(todos)}: {todo[:80]} ──")
|
||||
prev_section = ""
|
||||
if prev_results:
|
||||
prev_section = "\nPrevious todos completed:\n" + "\n".join(prev_results[-3:]) + "\n"
|
||||
msg = call_llm(client, QWEN["model"], [{"role": "user", "content": (
|
||||
f"Goal: {goal}\n{prev_section}"
|
||||
f"\nTodo ({idx}/{len(todos)}): {todo}\n\n"
|
||||
f"Write a concise task description for the worker:\n"
|
||||
f"- Files to modify\n- What to change\n- Verification steps\n- Completion criteria\n"
|
||||
f"IMPORTANT: Skip verification if the change was already done in a previous todo.")}],
|
||||
max_tok=2048)
|
||||
task = msg.content or todo
|
||||
result = worker_loop(task, workdir)
|
||||
log(f" ↳ {result['status']} ({result['iterations']} iters)")
|
||||
prev_results.append(f"Todo {idx}: {todo} — {result['status']} ({result['iterations']} iters)")
|
||||
|
||||
log("═══ Qwen: final verification ═══")
|
||||
msg = call_llm(client, QWEN["model"], [{"role": "user", "content": (
|
||||
f"Goal: {goal}\n\nAll todos executed. Run final verification:\n"
|
||||
f"1. `dotnet build`\n2. `dotnet test`\n3. Report any issues")}],
|
||||
tools=[t for t in TOOLS if t["function"]["name"] == "bash"], max_tok=4096)
|
||||
if msg.content:
|
||||
print(msg.content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="Qwen hierarchical loop (orchestrator + worker)")
|
||||
ap.add_argument("goal", nargs="*", help="Goal description")
|
||||
ap.add_argument("--dir", "-d", default=".", help="Working directory")
|
||||
ap.add_argument("--max-iter", "-m", type=int, default=15, help="Max worker iterations per todo")
|
||||
args = ap.parse_args()
|
||||
goal = " ".join(args.goal) or "Current dir: review and improve code"
|
||||
orchestrator(goal, args.dir)
|
||||
Reference in New Issue
Block a user