55
66AI 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``
99and writes the results to Omeka, each model into its own six properties named
1010for that model.
1111
4343
4444Concurrency multiplies with the per-item model fan-out. Running the panel one
4545member 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
4848Resuming
4949--------
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
5863So the safe response to any failure is to run the same command again.
5964
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
8289Environment 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+
871929def 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 :
0 commit comments