Skip to content

Commit 4b317ee

Browse files
authored
Merge pull request #17 from boostcampwm-snu-2026-1/dev
Release: dev → main (CI, dead code 정리, fallback, 에러/로딩 UX)
2 parents 7fd48da + 2370232 commit 4b317ee

5 files changed

Lines changed: 140 additions & 163 deletions

File tree

.github/workflows/ci.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, dev]
6+
pull_request:
7+
branches: [main, dev]
8+
9+
jobs:
10+
frontend:
11+
name: Frontend build
12+
runs-on: ubuntu-latest
13+
defaults:
14+
run:
15+
working-directory: frontend
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: actions/setup-node@v4
19+
with:
20+
node-version: '20'
21+
cache: npm
22+
cache-dependency-path: frontend/package-lock.json
23+
- run: npm ci
24+
- run: npm run build
25+
26+
backend:
27+
name: Backend import & compile
28+
runs-on: ubuntu-latest
29+
defaults:
30+
run:
31+
working-directory: backend
32+
env:
33+
# dummy key so module import (which builds no client) is safe in CI
34+
OPENAI_API_KEY: sk-ci-dummy
35+
steps:
36+
- uses: actions/checkout@v4
37+
- uses: actions/setup-python@v5
38+
with:
39+
python-version: '3.11'
40+
- run: pip install -r requirements.txt
41+
- name: Compile all modules
42+
run: python -m compileall -q .
43+
- name: Import API app
44+
run: python -c "import app; print('app import OK')"

backend/pipeline.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,57 @@
3030
# --------------------------------------------------------------------------- #
3131
# (1) Find the most similar existing scenario in the DB
3232
# --------------------------------------------------------------------------- #
33+
def _scenario_details() -> dict:
34+
try:
35+
with open(DB_DIR / "scenario" / "meta.json", "r", encoding="utf-8") as f:
36+
return json.load(f).get("details", {})
37+
except Exception:
38+
return {}
39+
40+
41+
def _keyword_fallback(query: str, details: dict) -> str:
42+
"""Pick the scenario whose name+description shares the most words with the
43+
query. No LLM — used when the DBCall match is empty or errors out."""
44+
q = set(re.findall(r"[a-z0-9가-힣]+", query.lower()))
45+
best, best_score = None, -1
46+
for name, info in details.items():
47+
text = f"{name} {info.get('description', '')}".lower()
48+
score = len(q & set(re.findall(r"[a-z0-9가-힣]+", text)))
49+
if score > best_score:
50+
best, best_score = name, score
51+
return best if best_score > 0 else None
52+
53+
3354
def find_scenario(query: str, seed: int = None) -> str:
34-
"""Return the best-matching scenario name from db/scenario, or None."""
35-
db = DBCall(seed=seed)
36-
_, names = db.call_with_names(query, folder="scenario")
37-
if not names:
38-
print(" ⚠️ No matching scenario found in DB.")
39-
return None
40-
print(f" 🔎 Matched scenario(s): {names} -> using '{names[0]}'")
41-
return names[0]
55+
"""Return the best-matching scenario name from db/scenario.
56+
57+
Resilient: LLM match → keyword fallback → default to the first scenario.
58+
Returns None only when the scenario DB is empty/unreadable.
59+
"""
60+
details = _scenario_details()
61+
names = []
62+
try:
63+
db = DBCall(seed=seed)
64+
_, names = db.call_with_names(query, folder="scenario")
65+
except Exception as e:
66+
print(f" ⚠️ DBCall match failed ({e}); falling back.")
67+
68+
if names:
69+
print(f" 🔎 Matched scenario(s): {names} -> using '{names[0]}'")
70+
return names[0]
71+
72+
fb = _keyword_fallback(query, details)
73+
if fb:
74+
print(f" ↩️ Keyword fallback -> '{fb}'")
75+
return fb
76+
77+
if details:
78+
first = next(iter(details))
79+
print(f" ↩️ No overlap; defaulting to first scenario -> '{first}'")
80+
return first
81+
82+
print(" ⚠️ Scenario DB is empty.")
83+
return None
4284

4385

4486
# --------------------------------------------------------------------------- #

backend/script_builder.py

Lines changed: 1 addition & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from langchain_core.output_parsers import JsonOutputParser, StrOutputParser
1010
from dotenv import load_dotenv
1111
from pathlib import Path
12-
from developer_prompt import SELECT_MAP_PROMPT, PLACE_UNITS_PROMPT, RULE_CONFIG_PROMPT, GET_CONDITION_PROMPT, REFINE_SCRIPT_PROMPT
12+
from developer_prompt import SELECT_MAP_PROMPT, PLACE_UNITS_PROMPT, RULE_CONFIG_PROMPT, GET_CONDITION_PROMPT
1313
from db_call import DBCall
1414

1515
import common
@@ -413,156 +413,6 @@ def assemble_draft(self, state: ScriptDeveloperState) -> Dict:
413413
"loop_count": 1 # Start at 1 so initial verify + MAX_LOOPS-1 refines = 3 total
414414
}
415415

416-
def verify_script(self, state: ScriptDeveloperState) -> Dict:
417-
print("\n Verifying Script with Analyst...")
418-
current_json = state.get("final_json", {})
419-
420-
analyst_input_state = {
421-
"current_script": current_json,
422-
"current_phase": "compiler",
423-
"loop_count": state.get("loop_count", 0),
424-
"target_diffs": state.get("refined_diffs", None)
425-
}
426-
427-
try:
428-
# Call Analyst's verification logic
429-
result = self.analyst.verify_script(analyst_input_state)
430-
431-
is_valid = result.get("game_script_valid", False)
432-
feedback_list = result.get("game_script_feedback", [])
433-
434-
# Format feedback for LLM if it's a list
435-
feedback_str = "\n".join(feedback_list) if isinstance(feedback_list, list) else str(feedback_list)
436-
437-
if is_valid:
438-
print(" ✅ Analyst: Script is VALID.")
439-
# Log successful validation
440-
self._log_feedback(
441-
feedback="Script validation passed",
442-
is_valid=True,
443-
loop_count=state.get("loop_count", 0),
444-
step_num=getattr(self, 'current_step_num', None)
445-
)
446-
else:
447-
print(f" ❌ Analyst: Script is INVALID.\n Errors: {feedback_str}")
448-
# Log failed validation
449-
self._log_feedback(
450-
feedback=feedback_str,
451-
is_valid=False,
452-
loop_count=state.get("loop_count", 0),
453-
step_num=getattr(self, 'current_step_num', None)
454-
)
455-
456-
return {
457-
"is_script_valid": is_valid,
458-
"script_feedback": feedback_str
459-
}
460-
461-
except Exception as e:
462-
print(f" ⚠️ Analyst Verification Error: {e}")
463-
return {"is_script_valid": False, "script_feedback": str(e)}
464-
465-
def refine_script(self, state: ScriptDeveloperState) -> Dict:
466-
print("\n[ScriptDev] 🔧 Refining Script...")
467-
468-
current_json = state.get("final_json", {})
469-
feedback = state.get("script_feedback", "")
470-
user_intent = state.get("user_intent", "")
471-
loop_count = state.get("loop_count", 0) + 1
472-
473-
# Single difficulty — always refine "normal"
474-
failed_diffs = {"normal"}
475-
476-
prompt = ChatPromptTemplate.from_messages([
477-
("system", REFINE_SCRIPT_PROMPT),
478-
("user", """
479-
[User Intent]: {user_intent}
480-
[Error Feedback]: {feedback}
481-
[Current JSON]: {current_json}
482-
""")
483-
])
484-
chain = prompt | self.client | self.parser
485-
486-
merged_json = dict(current_json)
487-
any_fixed = False
488-
489-
for diff in failed_diffs:
490-
if diff not in current_json:
491-
continue
492-
diff_feedback = "\n".join(
493-
line for line in feedback.splitlines()
494-
if f"[{diff.upper()}]" in line.upper() or f"[{diff}]" in line.lower()
495-
) or feedback # fallback to full feedback if no per-diff lines
496-
497-
print(f" Fixing [{diff.upper()}]...")
498-
try:
499-
fixed = chain.invoke({
500-
"user_intent": user_intent,
501-
"feedback": diff_feedback,
502-
"current_json": json.dumps(current_json[diff], ensure_ascii=False)
503-
})
504-
if isinstance(fixed, dict):
505-
merged_json[diff] = fixed
506-
any_fixed = True
507-
print(f" ✅ [{diff.upper()}] Fixed")
508-
else:
509-
print(f" ⚠️ [{diff.upper()}] LLM returned non-dict, skipping")
510-
except Exception as e:
511-
print(f" ❌ [{diff.upper()}] Fix failed: {e}")
512-
513-
if any_fixed:
514-
print(f" ✅ Script Refined (Loop {loop_count})")
515-
else:
516-
print(f" ⚠️ No difficulties were fixed (Loop {loop_count})")
517-
518-
return {
519-
"final_json": merged_json,
520-
"loop_count": loop_count,
521-
"is_script_valid": False,
522-
"refined_diffs": list(failed_diffs)
523-
}
524-
525-
def _log_feedback(self, feedback: str, is_valid: bool, loop_count: int, step_num: int = None):
526-
"""Log feedback for script validation/refinement."""
527-
feedback_entry = {
528-
"timestamp": datetime.datetime.now().isoformat(),
529-
"source": "script_developer",
530-
"phase": "script_developer",
531-
"loop_count": loop_count,
532-
"is_valid": is_valid,
533-
"feedback": feedback
534-
}
535-
if step_num is not None:
536-
feedback_entry["step_num"] = step_num
537-
538-
self.feedback_logs.append(feedback_entry)
539-
540-
# Save immediately
541-
try:
542-
with open(self.feedback_log_path, "w", encoding="utf-8") as f:
543-
json.dump(self.feedback_logs, f, indent=2, ensure_ascii=False)
544-
except Exception as e:
545-
print(f" ⚠️ Failed to save feedback log: {e}")
546-
547-
def check_script_validity(self, state: ScriptDeveloperState):
548-
is_valid = state.get("is_script_valid", False)
549-
loop_count = state.get("loop_count", 0)
550-
MAX_LOOPS = 3
551-
552-
if is_valid:
553-
return "valid"
554-
elif loop_count >= MAX_LOOPS:
555-
print(f" ⚠️ Max refine loops ({MAX_LOOPS}) reached. Returning to router.")
556-
self._log_feedback(
557-
feedback=f"Max refine loops ({MAX_LOOPS}) reached — returning to router",
558-
is_valid=False,
559-
loop_count=loop_count,
560-
step_num=getattr(self, 'current_step_num', None)
561-
)
562-
return "max_retries"
563-
else:
564-
return "invalid"
565-
566416
def run(self, user_intent: str, gdd: Dict, meta_feedback: str = None, step_num: int = None) -> Dict[str, Any]:
567417
self.current_step_num = step_num # Store for use in other methods
568418
print(f"\n[ScriptDev] 🚀 Starting Script Generation Workflow for: '{user_intent}' (Step {step_num})")

frontend/src/App.jsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,25 @@ import MiniMap from './components/MiniMap'
66
import SimPlayback from './components/SimPlayback'
77
import JsonView from './components/JsonView'
88

9+
// Map backend error codes / raw messages to friendly Korean text.
10+
function friendlyError(err) {
11+
const map = {
12+
no_matching_scenario: '비슷한 시나리오를 찾지 못했어요. 프롬프트를 조금 더 구체적으로 적어보세요.',
13+
failed_to_load_scenario: '시나리오 데이터를 불러오지 못했어요. 잠시 후 다시 시도해주세요.',
14+
}
15+
if (map[err]) return map[err]
16+
if (typeof err === 'string' && /failed to fetch|networkerror|load failed/i.test(err)) {
17+
return '백엔드에 연결하지 못했어요. 서버가 실행 중인지 확인해주세요 (localhost:8000).'
18+
}
19+
return `생성에 실패했어요: ${err}`
20+
}
21+
922
export default function App() {
1023
const [catalog, setCatalog] = useState({ scenarios: [], maps: [] })
1124
const [loading, setLoading] = useState(false)
1225
const [error, setError] = useState(null)
1326
const [result, setResult] = useState(null)
27+
const [lastQuery, setLastQuery] = useState('')
1428

1529
useEffect(() => {
1630
getCatalog()
@@ -19,18 +33,19 @@ export default function App() {
1933
}, [])
2034

2135
async function handleGenerate(query) {
36+
setLastQuery(query)
2237
setLoading(true)
2338
setError(null)
2439
setResult(null)
2540
try {
2641
const res = await generate(query)
2742
if (res.error || !res.config) {
28-
setError(res.error || 'No config was generated for this prompt.')
43+
setError(friendlyError(res.error || 'no_matching_scenario'))
2944
} else {
3045
setResult(res)
3146
}
3247
} catch (e) {
33-
setError(e.message)
48+
setError(friendlyError(e.message))
3449
} finally {
3550
setLoading(false)
3651
}
@@ -56,9 +71,23 @@ export default function App() {
5671
onGenerate={handleGenerate}
5772
/>
5873

59-
{error && <div className="error">⚠️ {error}</div>}
74+
{error && !loading && (
75+
<div className="error">
76+
<span>⚠️ {error}</span>
77+
{lastQuery && (
78+
<button className="btn retry" onClick={() => handleGenerate(lastQuery)}>
79+
다시 시도
80+
</button>
81+
)}
82+
</div>
83+
)}
6084

61-
{loading && <div className="loading">생성 중… (DB 매칭 → 스크립트 작성)</div>}
85+
{loading && (
86+
<div className="loading">
87+
<span className="spinner" aria-hidden="true" />
88+
생성 중… (DB 매칭 → 스크립트 작성)
89+
</div>
90+
)}
6291

6392
{result && config && (
6493
<div className="result">

frontend/src/styles.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,23 @@ body {
8888
margin-top: 18px; padding: 14px 16px;
8989
background: #3b1414; border: 1px solid #7f1d1d; color: #fecaca;
9090
border-radius: 10px;
91+
display: flex; align-items: center; justify-content: space-between; gap: 12px;
9192
}
93+
.error .retry {
94+
background: #7f1d1d; border-color: #b91c1c; color: #fee2e2; white-space: nowrap;
95+
}
96+
.error .retry:hover { background: #991b1b; }
9297
.loading {
9398
margin-top: 18px; padding: 14px 16px; color: var(--muted);
9499
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
100+
display: flex; align-items: center; gap: 10px;
101+
}
102+
.spinner {
103+
width: 16px; height: 16px; border-radius: 50%;
104+
border: 2px solid var(--border); border-top-color: var(--accent);
105+
display: inline-block; animation: spin 0.7s linear infinite;
95106
}
107+
@keyframes spin { to { transform: rotate(360deg); } }
96108

97109
/* result */
98110
.result { margin-top: 24px; display: flex; flex-direction: column; gap: 20px; }

0 commit comments

Comments
 (0)