* feat(parse): port the browser generation-parameter parser to Python Adds utils/generation_params.py, a line-for-line port of extractGenerationParams in web/js/comfy-metadata.js, the parser behind the gallery, admin and metadata pages. The scan and live monitor can use it so every surface reads prompts the same way. The JavaScript parser is unchanged. The port reproduces the JavaScript value semantics the result depends on (property order, truthiness, String(), trim(), strict equality, Map keys, JSON.parse number rounding, and the inputs on which it throws), and runs its recursive tracing on an explicit stack so crafted graphs cannot exhaust the Python stack. Tests: - behaviour tests for each tracing rule, with JavaScript results as expected values, including the visit-limit boundary; - golden vectors of the JavaScript parser's results for every named case and every PNG fixture, so the comparison runs without Node; - a live comparison against the JavaScript parser when Node is present; - a randomized differential test over 9,000 generated graphs and workflows, coherent and corrupted, including inputs where the JavaScript throws. Five deliberate porting mistakes (trim, truthiness, prepend handling, visit limit, pair slot) each fail the tests. On a 38,602-file output folder, all 18,491 files with a workflow give identical results. * fix(scan): link the traced prompt when the pair resolver cannot decide The output scan skipped every file the pair resolver called ambiguous or unresolved, so workflows where several samplers share one prompt (a base pass, an upscale pass and a detailer) never reached the Library from a scan. It now links the positive prompt the shared parser traces for those files, the prompt the inspector and the legacy pages show. Files the resolver decides are stored as before, and graphs without a recognized sampler keep the legacy extractor (#252). Read-only scans of a 38,602-file output folder into temporary databases: - links: 1,500 before, 7,954 after; none of the 1,500 dropped or changed; - of the added links that differ from the pre-redesign scan, 1,275 replace a number, a LoRA tag, a fragment or a truncated prompt that scan stored. * refactor(parse): return a uniform pair from the SameValueZero key
93 lines
3.6 KiB
Python
93 lines
3.6 KiB
Python
"""Ingestion keeps the default resolver profile; only inspection routes moved (D1).
|
|
|
|
The live monitor and the admin scan write pairs and links to the user's
|
|
database, so their resolver semantics stay byte-for-byte today's. f03 (506
|
|
executable nodes) separates the profiles: the default profile's 500-node gate
|
|
leaves it unresolved, while the inspection profile resolves it.
|
|
"""
|
|
|
|
import logging
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
from utils import prompt_pair_resolver as resolver
|
|
|
|
try:
|
|
from tests import inspection_parity as parity
|
|
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
|
import inspection_parity as parity
|
|
|
|
GATED = "Resolution limit reached"
|
|
|
|
|
|
def _f03_metadata():
|
|
return {"prompt": parity.build("f03")["prompt"]}
|
|
|
|
|
|
class _Spy:
|
|
"""Wrap the real resolver, recording each call's profile and outcome."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def __call__(self, metadata, **kwargs):
|
|
result = resolver.resolve_prompt_pair(metadata, **kwargs)
|
|
self.calls.append((kwargs.get("profile", resolver.DEFAULT_PROFILE), result))
|
|
return result
|
|
|
|
def assert_default_and_gated(self, case):
|
|
case.assertTrue(self.calls, "the ingestion path resolved the metadata")
|
|
for profile, result in self.calls:
|
|
case.assertIs(profile, resolver.DEFAULT_PROFILE)
|
|
case.assertEqual((result["state"], result["reason"]), ("unresolved", GATED))
|
|
|
|
|
|
class TestIngestionKeepsDefaultProfile(unittest.TestCase):
|
|
def test_f03_separates_the_profiles(self):
|
|
metadata = _f03_metadata()
|
|
default = resolver.resolve_prompt_pair(metadata)
|
|
self.assertEqual((default["state"], default["reason"]), ("unresolved", GATED))
|
|
inspection = resolver.resolve_prompt_pair(
|
|
metadata, profile=resolver.INSPECTION_PROFILE
|
|
)
|
|
self.assertEqual(inspection["state"], "resolved")
|
|
|
|
def test_live_monitor_lookup_and_linking_use_the_default_profile(self):
|
|
from utils.image_monitor import ImageGenerationHandler
|
|
|
|
handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
|
handler.db_manager = Mock()
|
|
handler.db_manager.get_prompt_by_hash.return_value = None
|
|
handler.logger = logging.getLogger("test.ingestion-profile")
|
|
spy, metadata = _Spy(), _f03_metadata()
|
|
with patch("utils.image_monitor.resolve_prompt_pair", spy):
|
|
handler._find_prompt_from_metadata(metadata)
|
|
handler.link_image_to_prompt("/out/f03.png", {"id": 1}, metadata)
|
|
self.assertEqual(len(spy.calls), 2)
|
|
spy.assert_default_and_gated(self)
|
|
handler.db_manager.link_image_to_prompt.assert_called_once()
|
|
|
|
def test_admin_scan_ingestion_uses_the_default_profile(self):
|
|
from py.api import admin
|
|
|
|
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
|
|
mixin.db = Mock()
|
|
mixin.db.get_prompt_by_hash.return_value = None
|
|
mixin.db.save_prompt.return_value = 7
|
|
spy = _Spy()
|
|
with patch.object(admin, "resolve_prompt_pair", spy):
|
|
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
|
|
spy.assert_default_and_gated(self)
|
|
# The resolver leaves f03 unresolved; the scan links the prompt the
|
|
# browser parser traces, as the inspector shows it.
|
|
self.assertEqual(status, "added")
|
|
self.assertEqual(
|
|
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
|
|
)
|
|
mixin.db.link_image_to_prompt.assert_called_once()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|