Fix remaining code-review findings (15 bugs; #11 skipped as AID-compatible)
Backend: - provider: fall back to parsing a plain JSON body when a server ignores stream=true (was: silent empty turn); error if response has no text (#5) - memorybank: clamp cursors after undo/retry shrinks the action list, and translate summary_cursor (list position) to an Action.index boundary before comparing with Memory.source_end (#7) - memorybank: pinned memories now count toward the top_k budget (#8) - memorybank: cosine() returns 0.0 on dimension mismatch; changing the embedding model clears stored vectors so they re-embed (#9) - scripting: MAX_STORY_CARDS cap now counts cards inserted during the hook, so a script can't add unbounded cards in one turn (#10) - settings: /test tolerates non-dict JSON from /models (#13) - scenarios: import accepts worldInformation as a story-card source (#14) Frontend: - per-key debounce timers in PlotPanel and ScenarioEditor — editing two things within 600ms no longer drops the first save (#15, #16) - Continue button no longer discards typed input (#17) - failed retry resyncs actions from the server instead of leaving the removed action missing (#18) - Settings save/test surface errors instead of hanging on Testing… (#19) - InsightsPanel ignores stale responses from superseded requests (#20) - placeholder scan includes story-card trigger keys (#21) addStoryCard returning the 0-based index (falsy for the first card) matches real AI Dungeon per the scripting guidebook — kept, documented (#11). Statuses updated in CODE_REVIEW_FINDINGS.md; stale entries for previously fixed items (#1-4, #6, #12) corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
This commit is contained in:
co-authored by
Claude Fable 5
parent
3ee653a631
commit
253b533d3b
@@ -71,6 +71,10 @@ def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider:
|
||||
|
||||
|
||||
def cosine(a: list[float], b: list[float]) -> float:
|
||||
# Different lengths means the embedding model changed since this vector was
|
||||
# stored; zip() would silently score garbage.
|
||||
if len(a) != len(b):
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
|
||||
return dot / norm if norm else 0.0
|
||||
@@ -118,10 +122,12 @@ async def retrieve_memories(
|
||||
key=lambda pair: pair[0],
|
||||
reverse=True,
|
||||
)
|
||||
# Pinned memories are always used; the rest fill up to top_k by similarity.
|
||||
# Pinned memories are always used and count toward top_k, so the injected
|
||||
# set never exceeds the configured budget (unless pinned alone exceed it).
|
||||
top_k = max(1, settings.memory_top_k)
|
||||
used = [(score, m) for score, m in scored if m.pinned]
|
||||
used += [(score, m) for score, m in scored if not m.pinned][:top_k]
|
||||
remaining = max(0, top_k - len(used))
|
||||
used += [(score, m) for score, m in scored if not m.pinned][:remaining]
|
||||
used.sort(key=lambda pair: pair[0], reverse=True)
|
||||
|
||||
if update_stats:
|
||||
@@ -162,6 +168,11 @@ async def run_post_turn(adventure_id: int) -> None:
|
||||
settings = db.get(models.Settings, 1)
|
||||
if adventure is None or settings is None:
|
||||
return
|
||||
# Undo/retry can shrink the action list below a stored cursor, which
|
||||
# would stall summarization until the story grew past it again.
|
||||
count = len(story_actions(adventure))
|
||||
adventure.memory_cursor = min(adventure.memory_cursor, count)
|
||||
adventure.summary_cursor = min(adventure.summary_cursor, count)
|
||||
if adventure.auto_summarize:
|
||||
await _create_due_memories(adventure, settings, db)
|
||||
await _update_story_summary(adventure, settings, db)
|
||||
@@ -213,10 +224,17 @@ async def _update_story_summary(
|
||||
|
||||
# Fold in memories covering the uncovered stretch; fall back to raw story
|
||||
# text if memory creation is lagging (e.g. it just failed).
|
||||
# summary_cursor is a position into story_actions(); Memory.source_end is
|
||||
# an Action.index. Translate the cursor to an index boundary before
|
||||
# comparing — the two spaces diverge once actions are deleted or empty.
|
||||
if adventure.summary_cursor < len(actions):
|
||||
boundary = actions[adventure.summary_cursor].index
|
||||
else:
|
||||
boundary = actions[-1].index + 1 if actions else 0
|
||||
new_events = [
|
||||
m.text
|
||||
for m in adventure.memories
|
||||
if m.source_end is not None and m.source_end >= adventure.summary_cursor
|
||||
if m.source_end is not None and m.source_end >= boundary
|
||||
]
|
||||
if new_events:
|
||||
events_text = "\n".join(f"- {t}" for t in new_events)
|
||||
|
||||
@@ -119,9 +119,16 @@ class OpenAICompatibleProvider(Provider):
|
||||
if resp.status_code != 200:
|
||||
detail = (await resp.aread()).decode(errors="replace")[:500]
|
||||
raise ProviderError(self._friendly_http_error(resp.status_code, detail))
|
||||
# Some servers ignore stream=true and return one plain JSON
|
||||
# body; buffer non-SSE lines so we can fall back to it.
|
||||
saw_sse = False
|
||||
raw_lines: list[str] = []
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
if not saw_sse:
|
||||
raw_lines.append(line)
|
||||
continue
|
||||
saw_sse = True
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
debuglog.finish_entry(log, response="".join(received))
|
||||
@@ -137,6 +144,27 @@ class OpenAICompatibleProvider(Provider):
|
||||
if chunk:
|
||||
received.append(chunk)
|
||||
yield "text", chunk
|
||||
if not saw_sse:
|
||||
body_text = "\n".join(raw_lines).strip()
|
||||
try:
|
||||
payload = json.loads(body_text)
|
||||
except ValueError:
|
||||
raise ProviderError(
|
||||
"AI endpoint returned neither an SSE stream nor JSON: "
|
||||
+ body_text[:200]
|
||||
)
|
||||
reasoning = self._extract_reasoning(payload)
|
||||
if reasoning:
|
||||
yield "reasoning", reasoning
|
||||
chunk = self._extract_chunk(payload)
|
||||
if chunk:
|
||||
received.append(chunk)
|
||||
yield "text", chunk
|
||||
if not received:
|
||||
raise ProviderError(
|
||||
"AI endpoint returned a response with no text: "
|
||||
+ body_text[:200]
|
||||
)
|
||||
debuglog.finish_entry(log, response="".join(received))
|
||||
except httpx.ConnectError as exc:
|
||||
error = f"Could not connect to {self.base_url} — is the AI server running?"
|
||||
|
||||
@@ -130,7 +130,13 @@ def import_scenario(bundle: dict = Body(...), db: Session = Depends(get_db)):
|
||||
db.add(scenario)
|
||||
db.flush()
|
||||
|
||||
cards = bundle.get("storyCards") or bundle.get("worldInfo") or []
|
||||
# AI Dungeon exports have used all three names for the same list.
|
||||
cards = (
|
||||
bundle.get("storyCards")
|
||||
or bundle.get("worldInfo")
|
||||
or bundle.get("worldInformation")
|
||||
or []
|
||||
)
|
||||
for card in cards:
|
||||
if not isinstance(card, dict):
|
||||
continue
|
||||
|
||||
@@ -25,8 +25,17 @@ def read_settings(db: Session = Depends(get_db)):
|
||||
@router.put("", response_model=schemas.SettingsOut)
|
||||
def update_settings(payload: schemas.SettingsUpdate, db: Session = Depends(get_db)):
|
||||
settings = get_settings(db)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
fields = payload.model_dump(exclude_unset=True)
|
||||
embedding_model_changed = (
|
||||
"embedding_model" in fields
|
||||
and fields["embedding_model"] != settings.embedding_model
|
||||
)
|
||||
for field, value in fields.items():
|
||||
setattr(settings, field, value)
|
||||
if embedding_model_changed:
|
||||
# Vectors from the old model have a different dimensionality/space;
|
||||
# clear them so the post-turn task re-embeds with the new model.
|
||||
db.query(models.Memory).update({"embedding": None})
|
||||
db.commit()
|
||||
return settings
|
||||
|
||||
@@ -52,6 +61,6 @@ async def test_connection(db: Session = Depends(get_db)):
|
||||
try:
|
||||
data = resp.json()
|
||||
models_available = [m.get("id", "?") for m in data.get("data", [])]
|
||||
except ValueError:
|
||||
pass
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
pass # non-JSON or unexpected shape — connectivity is still confirmed
|
||||
return {"ok": True, "models": models_available}
|
||||
|
||||
@@ -31,6 +31,8 @@ function log(msg) {
|
||||
}
|
||||
var console = { log: log };
|
||||
|
||||
// Returns the new card's index, or false if a card with those keys exists —
|
||||
// matching real AI Dungeon. Note index 0 is falsy; that quirk is upstream's.
|
||||
function addStoryCard(keys, entry, type) {
|
||||
for (var i = 0; i < storyCards.length; i++) {
|
||||
if (storyCards[i].keys === keys) return false;
|
||||
|
||||
@@ -45,6 +45,7 @@ class ScriptPipeline:
|
||||
def _apply_cards(self, returned: list) -> None:
|
||||
existing = {c.id: c for c in self.adventure.story_cards}
|
||||
seen_ids = set()
|
||||
added = 0
|
||||
for item in returned:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
@@ -56,13 +57,14 @@ class ScriptPipeline:
|
||||
seen_ids.add(card_id)
|
||||
card = existing[card_id]
|
||||
card.keys, card.entry, card.type = keys, entry, card_type
|
||||
elif len(existing) + len(seen_ids) < MAX_STORY_CARDS:
|
||||
elif len(existing) + added < MAX_STORY_CARDS:
|
||||
self.db.add(
|
||||
models.StoryCard(
|
||||
adventure_id=self.adventure.id,
|
||||
keys=keys, entry=entry, type=card_type,
|
||||
)
|
||||
)
|
||||
added += 1
|
||||
for card_id, card in existing.items():
|
||||
if card_id not in seen_ids:
|
||||
self.db.delete(card)
|
||||
|
||||
Reference in New Issue
Block a user