Skip to content

Commit 4a92226

Browse files
authored
Merge pull request #149 from CyberSecDef/copilot/fix-brace-counting-json-parser
Fix /llm_log endpoint: replace brace-counting parser with one-JSON-per-line format
2 parents 0e493b0 + 2f6c168 commit 4a92226

4 files changed

Lines changed: 102 additions & 36 deletions

File tree

novelforge/__init__.py

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""NovelForge – Flask application factory."""
22

3+
import collections
34
import json
45
import logging
56
import sys
@@ -236,38 +237,22 @@ def get_llm_log() -> Response:
236237
return jsonify({"entries": []})
237238

238239
try:
239-
entries = []
240+
window: collections.deque = collections.deque(maxlen=10)
240241
with open(log_path, "r", encoding="utf-8") as f:
241-
content = f.read()
242-
243-
json_objects = []
244-
current_obj = ""
245-
brace_count = 0
246-
247-
for line in content.split('\n'):
248-
if line.strip().startswith('{') and brace_count == 0:
249-
if current_obj:
250-
json_objects.append(current_obj)
251-
current_obj = line + '\n'
252-
brace_count = line.count('{') - line.count('}')
253-
elif brace_count > 0:
254-
current_obj += line + '\n'
255-
brace_count += line.count('{') - line.count('}')
256-
if brace_count == 0:
257-
json_objects.append(current_obj)
258-
current_obj = ""
259-
260-
if current_obj:
261-
json_objects.append(current_obj)
262-
263-
for obj_str in json_objects[-10:]:
264-
try:
265-
entry = json.loads(obj_str)
266-
entries.append(entry)
267-
except json.JSONDecodeError:
268-
continue
269-
270-
return jsonify({"entries": entries})
242+
for line in f:
243+
stripped = line.strip()
244+
if not stripped:
245+
continue
246+
247+
try:
248+
window.append(json.loads(stripped))
249+
except json.JSONDecodeError:
250+
# llm.log is treated as JSONL: one complete JSON object per
251+
# non-empty line. Skip malformed/incomplete lines rather than
252+
# trying to resynchronise based on "{" prefixes, which can
253+
# corrupt pretty-printed nested JSON content.
254+
continue
255+
return jsonify({"entries": list(window)})
271256
except Exception as e:
272257
logger.error(f"Error reading LLM log: {e}")
273258
return jsonify({"entries": [], "error": str(e)})

novelforge/llm/client.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ def _log_llm_error(
281281
if exception_chain:
282282
error_log["exception_chain"] = exception_chain
283283

284-
llm_logger.info(json.dumps(error_log, indent=2))
284+
llm_logger.info(json.dumps(error_log))
285285
logger.error(
286286
"LLM error [%s] action=%s attempt=%d/%d status=%s: %s | response_body=%s",
287287
error_type, action, attempt, MAX_RETRIES, status_code,
@@ -350,12 +350,12 @@ def _build_request(
350350
"provider": provider.label,
351351
"url": provider.url,
352352
"headers": {
353-
"Authorization": f"Bearer {provider.api_key[:8]}..." if provider.api_key else "None",
353+
"Authorization": "Bearer [REDACTED]" if provider.api_key else "None",
354354
"Content-Type": "application/json",
355355
},
356356
"payload": payload,
357357
}
358-
llm_logger.info(json.dumps(request_log, indent=2))
358+
llm_logger.info(json.dumps(request_log))
359359

360360
return headers, payload
361361

@@ -383,7 +383,7 @@ def _handle_success(
383383
"headers": dict(resp.headers),
384384
"response": data,
385385
}
386-
llm_logger.info(json.dumps(response_log, indent=2))
386+
llm_logger.info(json.dumps(response_log))
387387

388388
# Accumulate token usage if available
389389
usage = data.get("usage")

novelforge/llm/image.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def call_image_api(prompt: str, *, filename_prefix: str = "illustration") -> str
4848
"model": config.IMAGE_MODEL,
4949
"prompt": prompt[:200] + "..." if len(prompt) > 200 else prompt,
5050
}
51-
llm_logger.info(json.dumps(request_log, indent=2))
51+
llm_logger.info(json.dumps(request_log))
5252

5353
for attempt in range(1, MAX_RETRIES + 1):
5454
try:

tests/test_integration.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,87 @@ def test_clear_log_available(self, client):
767767
r = client.post("/clear_log")
768768
assert r.status_code == 200
769769

770+
@pytest.fixture
771+
def _isolated_app(self, tmp_path, monkeypatch):
772+
"""Create an app with all dirs isolated under tmp_path."""
773+
import novelforge.config as cfg
774+
from novelforge import create_app, limiter
775+
776+
for attr, subdir in (
777+
("NOVELS_DIR", "novels"),
778+
("LOGS_DIR", "logs"),
779+
("SESSION_FILE_DIR", "sessions"),
780+
("EXPORT_DIR", "exports"),
781+
):
782+
d = tmp_path / subdir
783+
d.mkdir()
784+
monkeypatch.setattr(cfg, attr, str(d))
785+
786+
flask_app = create_app(testing=True)
787+
flask_app.config["SECRET_KEY"] = "test-secret"
788+
flask_app.config["WTF_CSRF_ENABLED"] = False
789+
limiter.enabled = False
790+
return flask_app, tmp_path / "logs"
791+
792+
def test_llm_log_parses_single_line_entries(self, _isolated_app):
793+
"""One-JSON-per-line log format is parsed correctly."""
794+
flask_app, logs_dir = _isolated_app
795+
entry1 = {"type": "request", "action": "test1", "timestamp": "2024-01-01 00:00:00"}
796+
entry2 = {"type": "response", "action": "test2", "timestamp": "2024-01-01 00:00:01"}
797+
(logs_dir / "llm.log").write_text(
798+
json.dumps(entry1) + "\n" + json.dumps(entry2) + "\n",
799+
encoding="utf-8",
800+
)
801+
802+
with flask_app.test_client() as c:
803+
r = c.get("/llm_log")
804+
805+
assert r.status_code == 200
806+
data = r.get_json()
807+
assert len(data["entries"]) == 2
808+
assert data["entries"][0]["type"] == "request"
809+
assert data["entries"][1]["type"] == "response"
810+
811+
def test_llm_log_parses_entries_with_braces_in_strings(self, _isolated_app):
812+
"""Brace characters inside JSON string values do not break parsing."""
813+
flask_app, logs_dir = _isolated_app
814+
entry = {
815+
"type": "request",
816+
"action": "test",
817+
"timestamp": "2024-01-01 00:00:00",
818+
"payload": {"key": "value with { brace } and another {brace}"},
819+
}
820+
(logs_dir / "llm.log").write_text(json.dumps(entry) + "\n", encoding="utf-8")
821+
822+
with flask_app.test_client() as c:
823+
r = c.get("/llm_log")
824+
825+
assert r.status_code == 200
826+
data = r.get_json()
827+
assert len(data["entries"]) == 1
828+
assert data["entries"][0]["payload"]["key"] == "value with { brace } and another {brace}"
829+
830+
def test_llm_log_returns_last_ten_entries(self, _isolated_app):
831+
"""Only the last 10 entries are returned when the log has more."""
832+
flask_app, logs_dir = _isolated_app
833+
entries = [
834+
{"type": "request", "seq": i, "timestamp": f"2024-01-01 00:00:{i:02d}"}
835+
for i in range(15)
836+
]
837+
(logs_dir / "llm.log").write_text(
838+
"\n".join(json.dumps(e) for e in entries) + "\n",
839+
encoding="utf-8",
840+
)
841+
842+
with flask_app.test_client() as c:
843+
r = c.get("/llm_log")
844+
845+
assert r.status_code == 200
846+
data = r.get_json()
847+
assert len(data["entries"]) == 10
848+
assert data["entries"][0]["seq"] == 5
849+
assert data["entries"][-1]["seq"] == 14
850+
770851

771852
class TestNovelforgeDebugEnvVar:
772853
"""Verify that NOVELFORGE_DEBUG controls debug mode in app.py entrypoint."""

0 commit comments

Comments
 (0)