Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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')"
58 changes: 50 additions & 8 deletions backend/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# --------------------------------------------------------------------------- #
Expand Down
152 changes: 1 addition & 151 deletions backend/script_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})")
Expand Down
37 changes: 33 additions & 4 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
Expand All @@ -56,9 +71,23 @@ export default function App() {
onGenerate={handleGenerate}
/>

{error && <div className="error">⚠️ {error}</div>}
{error && !loading && (
<div className="error">
<span>⚠️ {error}</span>
{lastQuery && (
<button className="btn retry" onClick={() => handleGenerate(lastQuery)}>
다시 시도
</button>
)}
</div>
)}

{loading && <div className="loading">생성 중… (DB 매칭 → 스크립트 작성)</div>}
{loading && (
<div className="loading">
<span className="spinner" aria-hidden="true" />
생성 중… (DB 매칭 → 스크립트 작성)
</div>
)}

{result && config && (
<div className="result">
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading