From a5cd42ae19acde96e7a83f672f5837e491f76cc6 Mon Sep 17 00:00:00 2001 From: rlafurud Date: Fri, 5 Jun 2026 17:15:15 +0900 Subject: [PATCH 1/4] Add GitHub Actions CI (frontend build + backend import/compile) (#14) Closes #6 Co-authored-by: ReokyoungKim Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..97fa40f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + frontend: + name: Frontend build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run build + + backend: + name: Backend import & compile + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + env: + # dummy key so module import (which builds no client) is safe in CI + OPENAI_API_KEY: sk-ci-dummy + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: pip install -r requirements.txt + - name: Compile all modules + run: python -m compileall -q . + - name: Import API app + run: python -c "import app; print('app import OK')" From af4b907ad046f57b7e2a206b6bbac19223db076d Mon Sep 17 00:00:00 2001 From: rlafurud Date: Fri, 5 Jun 2026 17:15:19 +0900 Subject: [PATCH 2/4] Remove unused verify/refine analyst code from script_builder (#13) The simplified build drops the analyst/verify loop, leaving verify_script, refine_script, _log_feedback, check_script_validity (and REFINE_SCRIPT_PROMPT) as dead code. Remove them. Graph build + import verified. Closes #11 Co-authored-by: ReokyoungKim Co-authored-by: Claude Opus 4.8 (1M context) --- backend/script_builder.py | 152 +------------------------------------- 1 file changed, 1 insertion(+), 151 deletions(-) diff --git a/backend/script_builder.py b/backend/script_builder.py index 721064e..fadd2b3 100644 --- a/backend/script_builder.py +++ b/backend/script_builder.py @@ -9,7 +9,7 @@ from langchain_core.output_parsers import JsonOutputParser, StrOutputParser from dotenv import load_dotenv from pathlib import Path -from developer_prompt import SELECT_MAP_PROMPT, PLACE_UNITS_PROMPT, RULE_CONFIG_PROMPT, GET_CONDITION_PROMPT, REFINE_SCRIPT_PROMPT +from developer_prompt import SELECT_MAP_PROMPT, PLACE_UNITS_PROMPT, RULE_CONFIG_PROMPT, GET_CONDITION_PROMPT from db_call import DBCall import common @@ -413,156 +413,6 @@ def assemble_draft(self, state: ScriptDeveloperState) -> Dict: "loop_count": 1 # Start at 1 so initial verify + MAX_LOOPS-1 refines = 3 total } - def verify_script(self, state: ScriptDeveloperState) -> Dict: - print("\n Verifying Script with Analyst...") - current_json = state.get("final_json", {}) - - analyst_input_state = { - "current_script": current_json, - "current_phase": "compiler", - "loop_count": state.get("loop_count", 0), - "target_diffs": state.get("refined_diffs", None) - } - - try: - # Call Analyst's verification logic - result = self.analyst.verify_script(analyst_input_state) - - is_valid = result.get("game_script_valid", False) - feedback_list = result.get("game_script_feedback", []) - - # Format feedback for LLM if it's a list - feedback_str = "\n".join(feedback_list) if isinstance(feedback_list, list) else str(feedback_list) - - if is_valid: - print(" βœ… Analyst: Script is VALID.") - # Log successful validation - self._log_feedback( - feedback="Script validation passed", - is_valid=True, - loop_count=state.get("loop_count", 0), - step_num=getattr(self, 'current_step_num', None) - ) - else: - print(f" ❌ Analyst: Script is INVALID.\n Errors: {feedback_str}") - # Log failed validation - self._log_feedback( - feedback=feedback_str, - is_valid=False, - loop_count=state.get("loop_count", 0), - step_num=getattr(self, 'current_step_num', None) - ) - - return { - "is_script_valid": is_valid, - "script_feedback": feedback_str - } - - except Exception as e: - print(f" ⚠️ Analyst Verification Error: {e}") - return {"is_script_valid": False, "script_feedback": str(e)} - - def refine_script(self, state: ScriptDeveloperState) -> Dict: - print("\n[ScriptDev] πŸ”§ Refining Script...") - - current_json = state.get("final_json", {}) - feedback = state.get("script_feedback", "") - user_intent = state.get("user_intent", "") - loop_count = state.get("loop_count", 0) + 1 - - # Single difficulty β€” always refine "normal" - failed_diffs = {"normal"} - - prompt = ChatPromptTemplate.from_messages([ - ("system", REFINE_SCRIPT_PROMPT), - ("user", """ - [User Intent]: {user_intent} - [Error Feedback]: {feedback} - [Current JSON]: {current_json} - """) - ]) - chain = prompt | self.client | self.parser - - merged_json = dict(current_json) - any_fixed = False - - for diff in failed_diffs: - if diff not in current_json: - continue - diff_feedback = "\n".join( - line for line in feedback.splitlines() - if f"[{diff.upper()}]" in line.upper() or f"[{diff}]" in line.lower() - ) or feedback # fallback to full feedback if no per-diff lines - - print(f" Fixing [{diff.upper()}]...") - try: - fixed = chain.invoke({ - "user_intent": user_intent, - "feedback": diff_feedback, - "current_json": json.dumps(current_json[diff], ensure_ascii=False) - }) - if isinstance(fixed, dict): - merged_json[diff] = fixed - any_fixed = True - print(f" βœ… [{diff.upper()}] Fixed") - else: - print(f" ⚠️ [{diff.upper()}] LLM returned non-dict, skipping") - except Exception as e: - print(f" ❌ [{diff.upper()}] Fix failed: {e}") - - if any_fixed: - print(f" βœ… Script Refined (Loop {loop_count})") - else: - print(f" ⚠️ No difficulties were fixed (Loop {loop_count})") - - return { - "final_json": merged_json, - "loop_count": loop_count, - "is_script_valid": False, - "refined_diffs": list(failed_diffs) - } - - def _log_feedback(self, feedback: str, is_valid: bool, loop_count: int, step_num: int = None): - """Log feedback for script validation/refinement.""" - feedback_entry = { - "timestamp": datetime.datetime.now().isoformat(), - "source": "script_developer", - "phase": "script_developer", - "loop_count": loop_count, - "is_valid": is_valid, - "feedback": feedback - } - if step_num is not None: - feedback_entry["step_num"] = step_num - - self.feedback_logs.append(feedback_entry) - - # Save immediately - try: - with open(self.feedback_log_path, "w", encoding="utf-8") as f: - json.dump(self.feedback_logs, f, indent=2, ensure_ascii=False) - except Exception as e: - print(f" ⚠️ Failed to save feedback log: {e}") - - def check_script_validity(self, state: ScriptDeveloperState): - is_valid = state.get("is_script_valid", False) - loop_count = state.get("loop_count", 0) - MAX_LOOPS = 3 - - if is_valid: - return "valid" - elif loop_count >= MAX_LOOPS: - print(f" ⚠️ Max refine loops ({MAX_LOOPS}) reached. Returning to router.") - self._log_feedback( - feedback=f"Max refine loops ({MAX_LOOPS}) reached β€” returning to router", - is_valid=False, - loop_count=loop_count, - step_num=getattr(self, 'current_step_num', None) - ) - return "max_retries" - else: - return "invalid" - def run(self, user_intent: str, gdd: Dict, meta_feedback: str = None, step_num: int = None) -> Dict[str, Any]: self.current_step_num = step_num # Store for use in other methods print(f"\n[ScriptDev] πŸš€ Starting Script Generation Workflow for: '{user_intent}' (Step {step_num})") From 2feab2f62dbe9cdafff55288adfe332db81703fc Mon Sep 17 00:00:00 2001 From: rlafurud Date: Fri, 5 Jun 2026 17:15:24 +0900 Subject: [PATCH 3/4] Add resilient scenario matching fallback (DBCall -> keyword -> default) (#15) find_scenario no longer returns None on a failed/empty LLM match: it falls back to keyword overlap over scenario meta, then to the first scenario, and wraps DBCall errors. Verified the fallback path without a live API key. Closes #2 Co-authored-by: ReokyoungKim Co-authored-by: Claude Opus 4.8 (1M context) --- backend/pipeline.py | 58 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/backend/pipeline.py b/backend/pipeline.py index fcff372..995b425 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -30,15 +30,57 @@ # --------------------------------------------------------------------------- # # (1) Find the most similar existing scenario in the DB # --------------------------------------------------------------------------- # +def _scenario_details() -> dict: + try: + with open(DB_DIR / "scenario" / "meta.json", "r", encoding="utf-8") as f: + return json.load(f).get("details", {}) + except Exception: + return {} + + +def _keyword_fallback(query: str, details: dict) -> str: + """Pick the scenario whose name+description shares the most words with the + query. No LLM β€” used when the DBCall match is empty or errors out.""" + q = set(re.findall(r"[a-z0-9κ°€-힣]+", query.lower())) + best, best_score = None, -1 + for name, info in details.items(): + text = f"{name} {info.get('description', '')}".lower() + score = len(q & set(re.findall(r"[a-z0-9κ°€-힣]+", text))) + if score > best_score: + best, best_score = name, score + return best if best_score > 0 else None + + def find_scenario(query: str, seed: int = None) -> str: - """Return the best-matching scenario name from db/scenario, or None.""" - db = DBCall(seed=seed) - _, names = db.call_with_names(query, folder="scenario") - if not names: - print(" ⚠️ No matching scenario found in DB.") - return None - print(f" πŸ”Ž Matched scenario(s): {names} -> using '{names[0]}'") - return names[0] + """Return the best-matching scenario name from db/scenario. + + Resilient: LLM match β†’ keyword fallback β†’ default to the first scenario. + Returns None only when the scenario DB is empty/unreadable. + """ + details = _scenario_details() + names = [] + try: + db = DBCall(seed=seed) + _, names = db.call_with_names(query, folder="scenario") + except Exception as e: + print(f" ⚠️ DBCall match failed ({e}); falling back.") + + if names: + print(f" πŸ”Ž Matched scenario(s): {names} -> using '{names[0]}'") + return names[0] + + fb = _keyword_fallback(query, details) + if fb: + print(f" ↩️ Keyword fallback -> '{fb}'") + return fb + + if details: + first = next(iter(details)) + print(f" ↩️ No overlap; defaulting to first scenario -> '{first}'") + return first + + print(" ⚠️ Scenario DB is empty.") + return None # --------------------------------------------------------------------------- # From 2370232b411b416d8b0a48cc472da465f9615ae8 Mon Sep 17 00:00:00 2001 From: rlafurud Date: Fri, 5 Jun 2026 17:15:28 +0900 Subject: [PATCH 4/4] Improve generation error handling and loading UX (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - friendlyError(): map backend codes (no_matching_scenario, ...) and network errors to clear Korean messages - error banner gets a 'λ‹€μ‹œ μ‹œλ„' retry button (re-runs last query) - loading shows an animated spinner Frontend build verified. Closes #7 Co-authored-by: ReokyoungKim Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/App.jsx | 37 +++++++++++++++++++++++++++++++++---- frontend/src/styles.css | 12 ++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fba05d0..64a7b18 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,11 +6,25 @@ import MiniMap from './components/MiniMap' import SimPlayback from './components/SimPlayback' import JsonView from './components/JsonView' +// Map backend error codes / raw messages to friendly Korean text. +function friendlyError(err) { + const map = { + no_matching_scenario: 'λΉ„μŠ·ν•œ μ‹œλ‚˜λ¦¬μ˜€λ₯Ό μ°Ύμ§€ λͺ»ν–ˆμ–΄μš”. ν”„λ‘¬ν”„νŠΈλ₯Ό 쑰금 더 ꡬ체적으둜 μ μ–΄λ³΄μ„Έμš”.', + failed_to_load_scenario: 'μ‹œλ‚˜λ¦¬μ˜€ 데이터λ₯Ό λΆˆλŸ¬μ˜€μ§€ λͺ»ν–ˆμ–΄μš”. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.', + } + if (map[err]) return map[err] + if (typeof err === 'string' && /failed to fetch|networkerror|load failed/i.test(err)) { + return 'λ°±μ—”λ“œμ— μ—°κ²°ν•˜μ§€ λͺ»ν–ˆμ–΄μš”. μ„œλ²„κ°€ μ‹€ν–‰ 쀑인지 ν™•μΈν•΄μ£Όμ„Έμš” (localhost:8000).' + } + return `생성에 μ‹€νŒ¨ν–ˆμ–΄μš”: ${err}` +} + export default function App() { const [catalog, setCatalog] = useState({ scenarios: [], maps: [] }) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [result, setResult] = useState(null) + const [lastQuery, setLastQuery] = useState('') useEffect(() => { getCatalog() @@ -19,18 +33,19 @@ export default function App() { }, []) async function handleGenerate(query) { + setLastQuery(query) setLoading(true) setError(null) setResult(null) try { const res = await generate(query) if (res.error || !res.config) { - setError(res.error || 'No config was generated for this prompt.') + setError(friendlyError(res.error || 'no_matching_scenario')) } else { setResult(res) } } catch (e) { - setError(e.message) + setError(friendlyError(e.message)) } finally { setLoading(false) } @@ -56,9 +71,23 @@ export default function App() { onGenerate={handleGenerate} /> - {error &&
⚠️ {error}
} + {error && !loading && ( +
+ ⚠️ {error} + {lastQuery && ( + + )} +
+ )} - {loading &&
생성 쀑… (DB λ§€μΉ­ β†’ 슀크립트 μž‘μ„±)
} + {loading && ( +
+
+ )} {result && config && (
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3730f6b..8a43f24 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -88,11 +88,23 @@ body { margin-top: 18px; padding: 14px 16px; background: #3b1414; border: 1px solid #7f1d1d; color: #fecaca; border-radius: 10px; + display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.error .retry { + background: #7f1d1d; border-color: #b91c1c; color: #fee2e2; white-space: nowrap; +} +.error .retry:hover { background: #991b1b; } .loading { margin-top: 18px; padding: 14px 16px; color: var(--muted); background: var(--panel); border: 1px solid var(--border); border-radius: 10px; + display: flex; align-items: center; gap: 10px; +} +.spinner { + width: 16px; height: 16px; border-radius: 50%; + border: 2px solid var(--border); border-top-color: var(--accent); + display: inline-block; animation: spin 0.7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } /* result */ .result { margin-top: 24px; display: flex; flex-direction: column; gap: 20px; }