This commit is contained in:
Mikan
2026-06-19 11:28:04 +03:00
commit 53c89829a8
80 changed files with 12482 additions and 0 deletions

View File

View File

@@ -0,0 +1,23 @@
"""Worker entrypoint: runs trigger checker + future background jobs."""
from __future__ import annotations
import asyncio
from app.logging_setup import get_logger, setup_logging
from app.workers.trigger_runner import main_loop as trigger_loop
log = get_logger("worker")
async def main():
setup_logging()
log.info("worker_starting")
# Run all background loops concurrently
await asyncio.gather(
trigger_loop(),
# Future: rag indexer, summary compactor, etc.
)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,190 @@
"""Trigger checker: scans due deferred triggers and fires them.
The actual firing = creating a new narrative step for the player to see,
OR a hidden technical message if the event is "offscreen".
"""
from __future__ import annotations
import asyncio
import json
import re
import uuid
from typing import Any, Dict, List
from sqlalchemy import select
from app.core.llm import LlmClient
from app.core.settings_service import cast_setting, get_all_settings
from app.core.state_validator import apply_patch, validate_state
from app.db import AsyncSessionLocal
from app.logging_setup import get_logger, setup_logging
from app.models import DeferredTrigger, Message, Session, World
from app.prompts.templates import get_prompt
log = get_logger("trigger_runner")
def _parse_time(t: str) -> int:
m = re.match(r"day_(\d+)_hour_(\d+)", t or "")
if m:
return int(m.group(1)) * 24 * 60 + int(m.group(2)) * 60
try:
from datetime import datetime
return int(datetime.fromisoformat(t).timestamp() // 60)
except Exception:
return 0
async def check_and_fire_triggers() -> int:
"""Find all unfired triggers whose fire_at <= current world time, fire them.
Returns the number of triggers fired.
"""
setup_logging()
async with AsyncSessionLocal() as db:
result = await db.execute(
select(DeferredTrigger, Session, World)
.join(Session, DeferredTrigger.session_id == Session.id)
.join(World, Session.world_id == World.id)
.where(DeferredTrigger.fired.is_(False))
)
rows = result.all()
if not rows:
return 0
fired = 0
for trigger, session, world in rows:
cur = _parse_time(world.current_time or "")
fire_at = _parse_time(trigger.fire_at)
if fire_at > cur:
continue
try:
await _fire_trigger(db, trigger, session, world)
fired += 1
except Exception as e:
log.error("trigger_fire_failed", trigger_id=str(trigger.id), error=str(e))
if fired:
await db.commit()
return fired
async def _fire_trigger(db, trigger: DeferredTrigger, session: Session, world: World) -> None:
"""Fire a single trigger: produce narrative + apply state patch."""
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
system_prompt = get_prompt("trigger_runner", world.language).format(
description=trigger.description,
payload=json.dumps(trigger.payload, ensure_ascii=False)[:600],
state=json.dumps(world.state, ensure_ascii=False)[:1000],
)
response = await llm.chat(
messages=[{"role": "system", "content": system_prompt}],
temperature=0.5,
max_tokens=500,
purpose="trigger",
session_id=session.id,
db=db,
)
# Parse response
parsed: Dict[str, Any] = {}
m = re.search(r"\{[\s\S]*\}", response.text or "")
if m:
try:
parsed = json.loads(m.group(0))
except json.JSONDecodeError:
pass
# Apply state patch
state_patch = parsed.get("state_patch", {})
if state_patch:
new_state = apply_patch(world.state, state_patch)
schema = world.definition.get("world_schema", {})
ok, errors = validate_state(new_state, schema)
if ok:
world.state = new_state
narrative = parsed.get("narrative", "")
should_notify = bool(parsed.get("should_notify_player", True))
# Save as message
next_seq_result = await db.execute(
select(Message.seq).where(Message.session_id == session.id).order_by(Message.seq.desc()).limit(1)
)
row = next_seq_result.first()
next_seq = (row[0] + 1) if row else 1
if should_notify and narrative:
msg = Message(
session_id=session.id,
seq=next_seq,
role="system",
kind="narrative_step",
content=f"[Событие] {narrative}",
payload={
"trigger_id": str(trigger.id),
"triggered_at": trigger.fire_at,
"outcome": parsed.get("outcome", trigger.description),
"world_time": world.current_time,
"player_state": world.state.get("player", {}),
"options": [], # triggers don't usually offer choices
},
is_pinned=True,
hidden=False,
)
else:
msg = Message(
session_id=session.id,
seq=next_seq,
role="system",
kind="technical_offscreen",
content=f"[Trigger fired: {trigger.description}] Outcome: {parsed.get('outcome', '')}",
payload={
"trigger_id": str(trigger.id),
"outcome": parsed.get("outcome", ""),
"state_patch": state_patch,
},
is_pinned=False,
hidden=True,
)
db.add(msg)
trigger.fired = True
log.info("trigger_fired", trigger_id=str(trigger.id), session_id=str(session.id))
async def main_loop():
"""Main worker loop. Polls every N seconds for due triggers."""
setup_logging()
log.info("trigger_worker_started")
while True:
try:
async with AsyncSessionLocal() as db:
enabled = await _get_setting(db, "triggers.enabled", True)
interval = int(await _get_setting(db, "triggers.check_interval", 30))
if enabled:
fired = await check_and_fire_triggers()
if fired:
log.info("triggers_fired", count=fired)
except Exception as e:
log.error("trigger_worker_iteration_failed", error=str(e))
await asyncio.sleep(max(5, int(await _get_setting_sleep())))
async def _get_setting(db, key: str, default):
from app.models import Setting
result = await db.execute(select(Setting).where(Setting.key == key))
row = result.scalars().first()
if row is None:
return default
return cast_setting(key, row.value)
async def _get_setting_sleep() -> int:
async with AsyncSessionLocal() as db:
return int(await _get_setting(db, "triggers.check_interval", 30))
if __name__ == "__main__":
asyncio.run(main_loop())