Skip to content

Commit bb62931

Browse files
committed
feat(sentiment): --from-cache, so writing answers stops needing a live endpoint
Seeding the cache from an offline run turned out not to be enough to write it. 01 builds a client per selected member before anything else, and skips any member it cannot reach; with SELFHOSTED_LLM_BASE_URL unset -- the normal state of a laptop with no tunnel open -- the whole run aborted with "No models available" while 12,098 answers sat in the cache. That is the write path depending on the annotate path. A member's answers can be complete without its server being reachable, which is exactly the situation self-hosting creates: the model runs on a cluster, the credentials live here, and the two never overlap in time. Publishing answers that exist should not require the ability to produce answers that do not. --from-cache builds no client and contacts no model. catalog_members() names the selected members from the registry, pending is always empty so an item with no cached answer is left alone rather than requested, and run membership now reads from labels rather than clients -- the same set on an ordinary run, and the only one that exists here. It refuses --force-reanalyze (nothing to re-analyze with) and --skip-update (one flag forbids annotating, the other forbids writing, so together they are a silent no-op). The skip message on the ordinary path now points at it, since "Skipping Qwen -- SELFHOSTED_LLM_BASE_URL not set" is otherwise a dead end for someone holding a complete set of answers. Also correct the module docstring, which still described a four-model panel and a fan-out of four.
1 parent 51b72f0 commit bb62931

2 files changed

Lines changed: 124 additions & 15 deletions

File tree

AI_sentiment_analysis/01_sentiment_analysis.py

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
AI Sentiment Analysis Pipeline for IWAC Omeka S Items — generation 2.
77
8-
Annotates items with the four-model panel defined in ``sentiment_core.PANEL``
8+
Annotates items with the five-model panel defined in ``sentiment_core.PANEL``
99
and writes the results to Omeka, each model into its own six properties named
1010
for that model.
1111
@@ -43,7 +43,7 @@
4343
4444
Concurrency multiplies with the per-item model fan-out. Running the panel one
4545
member at a time — the normal mode — keeps in-flight requests equal to
46-
``--concurrency``; running all four multiplies it by four.
46+
``--concurrency``; running the whole panel multiplies it by five.
4747
4848
Resuming
4949
--------
@@ -54,6 +54,11 @@
5454
- Results are cached per (item, model) as they are produced, so a resume asks
5555
each model only for what it has not already answered.
5656
- Only successful results are cached; errors are retried on the next run.
57+
- ``--from-cache`` writes what is cached and contacts no model at all, for a
58+
member annotated elsewhere — on your own GPU, typically, where the answers
59+
arrive as JSONL and ``04_import_offline_run.py`` seeds them. It needs no API
60+
key and no endpoint, because publishing answers that exist should not require
61+
the ability to produce answers that do not.
5762
5863
So the safe response to any failure is to run the same command again.
5964
@@ -78,6 +83,8 @@
7883
--models deepseek_v4_flash_0731 --model-timeout 300
7984
python AI_sentiment_analysis/01_sentiment_analysis.py \
8085
--item-ids @repair_ids.txt --models deepseek_v4_flash_0731 --force-reanalyze
86+
python AI_sentiment_analysis/01_sentiment_analysis.py --resource-class-id 36 \
87+
--models qwen3_8_27b --from-cache
8188
8289
Environment Variables
8390
---------------------
@@ -595,10 +602,15 @@ def jobs(self) -> Iterator[Tuple[Any, str, List[str]]]:
595602
self.bump("already_done")
596603
continue
597604

598-
pending = (
599-
list(self.clients)
600-
if self.args.force_reanalyze
601-
else [
605+
# ``--from-cache`` never annotates: an item with no cached answer is
606+
# left alone rather than requested, which is the whole point of a
607+
# run that holds no clients.
608+
if self.args.from_cache:
609+
pending: List[str] = []
610+
elif self.args.force_reanalyze:
611+
pending = list(self.clients)
612+
else:
613+
pending = [
602614
key
603615
for key in self.clients
604616
if key not in written
@@ -608,7 +620,6 @@ def jobs(self) -> Iterator[Tuple[Any, str, List[str]]]:
608620
**self.expected_provenance[key],
609621
)
610622
]
611-
)
612623
yield item_id, content, pending
613624
produced += 1
614625

@@ -656,12 +667,15 @@ def annotate(self, job: Tuple[Any, str, List[str]]) -> None:
656667
if self.args.skip_update:
657668
return
658669

670+
# Membership of the run is ``labels``, not ``clients``: the two are the
671+
# same set on an ordinary run, and under --from-cache there are no
672+
# clients at all.
659673
results = {
660674
key: result
661675
for key, result in self.cache.results_for(
662676
item_id, expected=self.expected_provenance
663677
).items()
664-
if key in self.clients
678+
if key in self.labels
665679
}
666680
if not results:
667681
return
@@ -818,6 +832,13 @@ def build_argument_parser() -> argparse.ArgumentParser:
818832
help="Re-PATCH items that already carry values, reusing answers whose "
819833
"cached provenance still matches",
820834
)
835+
behaviour.add_argument(
836+
"--from-cache", action="store_true",
837+
help="Write cached answers only; contact no model and build no client. "
838+
"For a member annotated offline on your own GPU (see "
839+
"04_import_offline_run.py) — items with no cached answer are left "
840+
"alone rather than requested",
841+
)
821842
behaviour.add_argument("--yes", action="store_true", help="Skip the confirmation prompt")
822843
behaviour.add_argument(
823844
"--verbose", action="store_true", help="Log per-model failures as they happen"
@@ -835,6 +856,19 @@ def validate_arguments(args: argparse.Namespace) -> List[int]:
835856
)
836857
if args.limit is not None and args.limit < 1:
837858
raise ValueError("--limit must be at least 1")
859+
if args.from_cache:
860+
# Both of these ask for annotation, which is the one thing this mode
861+
# cannot do. Failing here beats a run that silently writes nothing.
862+
if args.force_reanalyze:
863+
raise ValueError(
864+
"--from-cache contacts no model, so --force-reanalyze has "
865+
"nothing to re-analyze with"
866+
)
867+
if args.skip_update:
868+
raise ValueError(
869+
"--from-cache and --skip-update together would do nothing: one "
870+
"forbids annotating, the other forbids writing"
871+
)
838872
if args.item_ids:
839873
if args.item_set_id or args.resource_class_id is not None:
840874
raise ValueError(
@@ -868,6 +902,30 @@ def selected_model_keys(raw_models: Optional[str]) -> List[str]:
868902
return selected
869903

870904

905+
def catalog_members(selected: List[str]) -> Tuple[Dict[str, str], Dict[str, str]]:
906+
"""Labels and model ids for a run that will call no model at all.
907+
908+
A member's answers can be complete in the cache without its endpoint being
909+
reachable — imported from an offline run on your own GPU, typically, where
910+
the model ran on a cluster and the write happens here. ``build_clients``
911+
refuses such a member outright, which is correct when the run may need to
912+
annotate and wrong when every answer is already in hand: it makes the write
913+
path depend on the annotate path, so publishing answers that exist requires
914+
the ability to produce answers that do not.
915+
916+
Raises rather than skipping, because ``--from-cache`` names its members
917+
explicitly and a member that cannot even be looked up in the registry is a
918+
typo, not an unreachable server.
919+
"""
920+
labels: Dict[str, str] = {}
921+
model_ids: Dict[str, str] = {}
922+
for key in selected:
923+
member = PANEL[key]
924+
labels[key] = member.label
925+
model_ids[key] = get_model_option(member.registry_key).model
926+
return labels, model_ids
927+
928+
871929
def available_clients(
872930
selected: List[str], model_timeout: float
873931
) -> Tuple[Dict[str, BaseLLMClient], Dict[str, str], Dict[str, str]]:
@@ -877,6 +935,8 @@ def available_clients(
877935
for label, reason in skipped:
878936
console.print(f"[yellow]![/] Skipping [bold]{label}[/] — {reason}")
879937
console.print("[dim] Qwen and DeepSeek both need OPENROUTER_API_KEY.[/]")
938+
console.print("[dim] A member whose answers are already cached can be "
939+
"written with --from-cache, which needs no endpoint.[/]")
880940
if not clients:
881941
raise RuntimeError("No models available — nothing to run")
882942
return clients, labels, model_ids
@@ -926,16 +986,22 @@ def show_configuration(
926986
"\n".join(
927987
f"{labels[key]} [dim]{model_ids[key]} → "
928988
f"iwac:{PANEL[key].property_prefix}*[/]"
929-
for key in clients
989+
for key in labels
930990
),
931991
)
932992
table.add_row(
933993
"Reasoning",
934994
", ".join(
935995
f"{labels[key]}={PANEL_REASONING_EFFECTIVE[key].split(' ')[0]}"
936-
for key in clients
996+
for key in labels
937997
),
938998
)
999+
if args.from_cache:
1000+
table.add_row(
1001+
"Source of answers",
1002+
"[bold yellow]cache only[/] — no model is contacted; items with no "
1003+
"cached answer are left alone",
1004+
)
9391005
table.add_row(
9401006
"Model timeout",
9411007
f"{args.model_timeout:g}s total / "
@@ -989,10 +1055,13 @@ def prepare_run(
9891055
item_set_ids: List[int],
9901056
) -> PreparedSentimentRun:
9911057
client = OmekaClient.from_env()
992-
clients, labels, model_ids = available_clients(
993-
selected_model_keys(args.models), args.model_timeout
994-
)
995-
members = [PANEL[key] for key in clients]
1058+
selected = selected_model_keys(args.models)
1059+
if args.from_cache:
1060+
clients: Dict[str, BaseLLMClient] = {}
1061+
labels, model_ids = catalog_members(selected)
1062+
else:
1063+
clients, labels, model_ids = available_clients(selected, args.model_timeout)
1064+
members = [PANEL[key] for key in labels]
9961065
property_ids = panel_property_ids(client, members, skip_update=args.skip_update)
9971066
system_prompt = load_system_prompt()
9981067
prompt_id = prompt_fingerprint(system_prompt)
@@ -1002,7 +1071,7 @@ def prepare_run(
10021071
"reasoning": PANEL_REASONING_EFFECTIVE[key],
10031072
"prompt": prompt_id,
10041073
}
1005-
for key in clients
1074+
for key in labels
10061075
}
10071076
explicit_ids = parse_item_ids(args.item_ids) if args.item_ids else None
10081077
if explicit_ids is not None:

tests/test_sentiment_panel.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import json
1818
import logging
19+
import os
1920

2021
import pytest
2122

@@ -727,6 +728,45 @@ def test_item_ids_may_not_be_combined_with_a_listing():
727728
assert sentiment_run.validate_arguments(ok) == []
728729

729730

731+
def test_from_cache_needs_no_endpoint_to_name_its_members():
732+
"""Writing cached answers must not require the ability to produce more.
733+
734+
``build_clients`` refuses a member whose endpoint is unset, which is right
735+
when the run may annotate and wrong when every answer is already in hand —
736+
the self-hosted member is annotated on a cluster and written from here, with
737+
no tunnel open. ``catalog_members`` is the half that only names them.
738+
"""
739+
labels, model_ids = sentiment_run.catalog_members(["qwen3_8_27b"])
740+
assert labels == {"qwen3_8_27b": PANEL["qwen3_8_27b"].label}
741+
assert model_ids == {"qwen3_8_27b": "Qwen/Qwen3.8-27B"}
742+
743+
# ...and the ordinary path still refuses it without an endpoint, so the two
744+
# modes are genuinely different rather than one quietly becoming the other.
745+
monkey = os.environ.pop("SELFHOSTED_LLM_BASE_URL", None)
746+
try:
747+
clients, _, _, skipped = sentiment_run.build_clients(["qwen3_8_27b"])
748+
assert clients == {}
749+
assert [label for label, _ in skipped] == [PANEL["qwen3_8_27b"].label]
750+
finally:
751+
if monkey is not None:
752+
os.environ["SELFHOSTED_LLM_BASE_URL"] = monkey
753+
754+
755+
@pytest.mark.parametrize("conflicting", ["--force-reanalyze", "--skip-update"])
756+
def test_from_cache_rejects_flags_that_ask_for_annotation(conflicting):
757+
"""Both would make the run a no-op, and silently.
758+
759+
``--force-reanalyze`` has nothing to re-analyze with when no client exists,
760+
and ``--skip-update`` forbids the only thing this mode does.
761+
"""
762+
parser = sentiment_run.build_argument_parser()
763+
args = parser.parse_args(
764+
["--resource-class-id", "36", "--from-cache", conflicting]
765+
)
766+
with pytest.raises(ValueError):
767+
sentiment_run.validate_arguments(args)
768+
769+
730770
def test_validator_does_not_change_the_wire_schema():
731771
"""The provider contract and the prompt fingerprint must be untouched.
732772

0 commit comments

Comments
 (0)