circex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. circex/__init__.py +3 -0
  2. circex/__main__.py +4 -0
  3. circex/bot/__init__.py +23 -0
  4. circex/bot/aggregate.py +185 -0
  5. circex/bot/poster.py +119 -0
  6. circex/bot/skyportal_map.py +257 -0
  7. circex/cache/__init__.py +5 -0
  8. circex/cache/llm.py +153 -0
  9. circex/classify/__init__.py +6 -0
  10. circex/classify/harvest.py +91 -0
  11. circex/classify/sn_type.py +138 -0
  12. circex/cli.py +914 -0
  13. circex/config.py +34 -0
  14. circex/consume/__init__.py +13 -0
  15. circex/consume/processor.py +136 -0
  16. circex/consume/sources.py +57 -0
  17. circex/data/__init__.py +16 -0
  18. circex/data/archive.py +105 -0
  19. circex/data/subset.py +129 -0
  20. circex/data/swift_gold.py +97 -0
  21. circex/data/telescope_aliases.yaml +44 -0
  22. circex/data/telescopes.py +50 -0
  23. circex/data/topics.py +75 -0
  24. circex/db/__init__.py +12 -0
  25. circex/db/connection.py +64 -0
  26. circex/db/indexer.py +202 -0
  27. circex/eval/__init__.py +29 -0
  28. circex/eval/metrics.py +443 -0
  29. circex/eval/plot.py +214 -0
  30. circex/eval/report.py +273 -0
  31. circex/eval/runner.py +65 -0
  32. circex/eval/vidushi_adapter.py +142 -0
  33. circex/extract/__init__.py +0 -0
  34. circex/extract/llm/__init__.py +16 -0
  35. circex/extract/llm/chunker.py +163 -0
  36. circex/extract/llm/claude.py +241 -0
  37. circex/extract/llm/ollama.py +297 -0
  38. circex/extract/llm/prompt.py +269 -0
  39. circex/extract/protocol.py +68 -0
  40. circex/extract/regex/__init__.py +31 -0
  41. circex/extract/regex/classification.py +115 -0
  42. circex/extract/regex/coords.py +99 -0
  43. circex/extract/regex/dates.py +83 -0
  44. circex/extract/regex/extractor.py +188 -0
  45. circex/extract/regex/mag_table.py +529 -0
  46. circex/extract/regex/redshift.py +135 -0
  47. circex/extract/regex/regex_events.py +154 -0
  48. circex/extract/timing.py +171 -0
  49. circex/fetch/__init__.py +5 -0
  50. circex/fetch/gcn_poller.py +75 -0
  51. circex/label.py +95 -0
  52. circex/schema/__init__.py +45 -0
  53. circex/schema/circular_extraction.py +97 -0
  54. circex/schema/classification.py +70 -0
  55. circex/schema/datetime_.py +38 -0
  56. circex/schema/dump.py +89 -0
  57. circex/schema/event.py +28 -0
  58. circex/schema/extraction_meta.py +55 -0
  59. circex/schema/follow_up.py +29 -0
  60. circex/schema/localization.py +56 -0
  61. circex/schema/photometry.py +169 -0
  62. circex/schema/redshift.py +34 -0
  63. circex/schema/reporter.py +41 -0
  64. circex/schema/span.py +30 -0
  65. circex/schema/spectral_lines.py +39 -0
  66. circex/schema/time_offset.py +26 -0
  67. circex/search/__init__.py +17 -0
  68. circex/search/fts.py +162 -0
  69. circex/server/__init__.py +22 -0
  70. circex/server/protocol.py +49 -0
  71. circex/server/registry.py +53 -0
  72. circex/server/store.py +252 -0
  73. circex/server/tools.py +290 -0
  74. circex/server/worker.py +101 -0
  75. circex/taxonomy.py +156 -0
  76. circex/taxonomy_data/LICENSE.timedomain-taxonomy +21 -0
  77. circex/taxonomy_data/README.md +7 -0
  78. circex/taxonomy_data/cataclysmic.yaml +68 -0
  79. circex/taxonomy_data/eclipsing.yaml +47 -0
  80. circex/taxonomy_data/eruptive.yaml +56 -0
  81. circex/taxonomy_data/nonstellar.yaml +29 -0
  82. circex/taxonomy_data/pulsating.yaml +131 -0
  83. circex/taxonomy_data/rotating.yaml +21 -0
  84. circex/taxonomy_data/supernovae.yaml +158 -0
  85. circex/taxonomy_data/top.yaml +22 -0
  86. circex/train/__init__.py +15 -0
  87. circex/train/dataset.py +125 -0
  88. circex-0.1.0.dist-info/METADATA +828 -0
  89. circex-0.1.0.dist-info/RECORD +92 -0
  90. circex-0.1.0.dist-info/WHEEL +4 -0
  91. circex-0.1.0.dist-info/entry_points.txt +2 -0
  92. circex-0.1.0.dist-info/licenses/LICENSE +21 -0
circex/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Circex: LLM-based extractor for GCN optical circulars."""
2
+
3
+ __version__ = "0.1.0"
circex/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from circex.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
circex/bot/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """SkyPortal poster bot — maps CircularExtraction to SkyPortal API writes.
2
+
3
+ See docs/design_skyportal_bot.md. The mapping (skyportal_map) is pure and
4
+ offline-testable; the poster (poster) defaults to dry-run and only hits a live
5
+ SkyPortal with an explicit token + flag.
6
+ """
7
+
8
+ from circex.bot.aggregate import aggregate_event, gather_by_xref
9
+ from circex.bot.skyportal_map import (
10
+ PhotometryPoint,
11
+ SkyPortalActions,
12
+ SourceUpsert,
13
+ to_actions,
14
+ )
15
+
16
+ __all__ = [
17
+ "PhotometryPoint",
18
+ "SkyPortalActions",
19
+ "SourceUpsert",
20
+ "aggregate_event",
21
+ "gather_by_xref",
22
+ "to_actions",
23
+ ]
@@ -0,0 +1,185 @@
1
+ """Aggregate an event's circulars into ONE SkyPortal source.
2
+
3
+ The single-circular path (`circex post`) posts one bulletin at a time. A real
4
+ transient is described across many circulars — a discovery circular carries the
5
+ position, follow-ups carry the light curve. `aggregate_event` fuses them:
6
+
7
+ - position preferring the refined optical-counterpart circular over a coarse
8
+ gamma-ray trigger box (which can be degrees off),
9
+ - photometry unioned across every circular (each point keeps its own source
10
+ circular id in altdata),
11
+ - deduped so a re-run or an overlapping report doesn't double-post,
12
+ - a single redshift if any circular states a valid one.
13
+
14
+ `gather_by_xref` discovers an event's circulars from a seed by walking the GCN
15
+ cross-reference graph the extractor already recovers — no event-search API needed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from collections.abc import Callable, Iterable
22
+ from datetime import datetime
23
+ from typing import Any
24
+
25
+ from circex.bot.skyportal_map import SkyPortalActions, SourceUpsert, to_actions
26
+ from circex.extract.protocol import Circular, Extractor
27
+ from circex.extract.regex.regex_events import extract_gcn_xrefs_with_positions
28
+ from circex.schema import Event
29
+
30
+ # Prefer a refined optical-counterpart position over a coarse gamma-ray trigger box.
31
+ # Both kinds of circular carry coordinates, but the trigger box can be degrees off
32
+ # (e.g. a Fermi-GBM localization vs the arcsec MASTER OT position).
33
+ _REFINED_POSITION_CUES = re.compile(
34
+ r"optical\s+(?:counterpart|afterglow|transient)|\bOT\b|counterpart|afterglow"
35
+ r"|arcsec|UVOT|XRT\s+position|enhanced\s+XRT|refined\s+(?:position|localization)"
36
+ r"|MASTER\s+OT|discover",
37
+ re.IGNORECASE,
38
+ )
39
+ _COARSE_POSITION_CUES = re.compile(
40
+ r"\bGBM\b|\bGRM\b|ECLAIRs|real-?time\s+localization|initial\s+localization"
41
+ r"|error\s+(?:radius|circle)\s+of\s+\d|gamma-?ray\s+(?:burst\s+)?(?:localization|detection)",
42
+ re.IGNORECASE,
43
+ )
44
+
45
+
46
+ def gather_by_xref(
47
+ seed_id: int,
48
+ fetch: Callable[[int], dict[str, Any] | None],
49
+ max_hops: int = 1,
50
+ ) -> list[dict[str, Any]]:
51
+ """Collect an event's circular records by BFS over the GCN cross-reference graph.
52
+
53
+ `fetch(circular_id)` returns a record ({circularId, subject, body, eventId})
54
+ or None (404). `max_hops` bounds the walk: 1 = the seed plus everything it
55
+ cites (usually the whole flurry). Records are returned in discovery order.
56
+ """
57
+ seen: dict[int, dict[str, Any]] = {}
58
+ frontier = [seed_id]
59
+ hops = 0
60
+ while frontier and hops <= max_hops:
61
+ next_frontier: list[int] = []
62
+ for cid in frontier:
63
+ if cid in seen:
64
+ continue
65
+ record = fetch(cid)
66
+ if record is None:
67
+ continue
68
+ seen[cid] = record
69
+ for xid, _, _ in extract_gcn_xrefs_with_positions(str(record.get("body") or "")):
70
+ if xid not in seen:
71
+ next_frontier.append(xid)
72
+ frontier = next_frontier
73
+ hops += 1
74
+ return list(seen.values())
75
+
76
+
77
+ def _position_score(record: dict[str, Any]) -> int:
78
+ """+1 if the circular's text reads like a refined/optical position, -1 if coarse."""
79
+ text = f"{record.get('subject', '') or ''}\n{record.get('body', '') or ''}"
80
+ score = 0
81
+ if _REFINED_POSITION_CUES.search(text):
82
+ score += 1
83
+ if _COARSE_POSITION_CUES.search(text):
84
+ score -= 1
85
+ return score
86
+
87
+
88
+ def _record_to_circular(record: dict[str, Any], trigger_time: datetime | None) -> Circular:
89
+ return Circular(
90
+ circular_id=int(record.get("circularId") or 0),
91
+ subject=str(record.get("subject") or ""),
92
+ body=str(record.get("body") or ""),
93
+ event_id=record.get("eventId") or None,
94
+ trigger_time=trigger_time,
95
+ )
96
+
97
+
98
+ def _dedup_key(point: Any) -> tuple[Any, ...]:
99
+ return (point.obj_id, point.filter, point.magsys, round(point.mjd, 5), point.mag)
100
+
101
+
102
+ def aggregate_event(
103
+ records: Iterable[dict[str, Any]],
104
+ extractor: Extractor,
105
+ *,
106
+ trigger_time: datetime | None = None,
107
+ instrument_map: dict[str, int] | None = None,
108
+ default_instrument_id: int | None = None,
109
+ group_ids: list[int] | None = None,
110
+ event_name: str | list[str] | None = None,
111
+ ) -> SkyPortalActions:
112
+ """Fuse an event's circulars into one source + a deduped, attributed light curve."""
113
+ group_ids = group_ids or []
114
+ records = list(records)
115
+ extractions = [extractor.extract(_record_to_circular(r, trigger_time)) for r in records]
116
+
117
+ # Position: prefer the refined optical-counterpart position over a coarse
118
+ # trigger box. Rank localization-bearing circulars by text cues; ties keep
119
+ # discovery order. Falls back to the first localization when none score.
120
+ candidates = [
121
+ (_position_score(rec), -i, ext.localization)
122
+ for i, (rec, ext) in enumerate(zip(records, extractions, strict=True))
123
+ if ext.localization is not None
124
+ ]
125
+ localization = max(candidates, key=lambda c: (c[0], c[1]))[2] if candidates else None
126
+ # Event name: caller override, else the first one any circular names.
127
+ name = event_name
128
+ if name is None:
129
+ name = next(
130
+ (e.event.event_name for e in extractions if e.event and e.event.event_name), None
131
+ )
132
+
133
+ photometry: list[Any] = []
134
+ comments: list[str] = []
135
+ skipped = 0
136
+ redshift: tuple[float, float | None] | None = None
137
+ event = Event(event_name=name) if name is not None else None
138
+
139
+ for extraction in extractions:
140
+ if event is not None:
141
+ extraction.event = event
142
+ extraction.localization = localization
143
+ actions = to_actions(
144
+ extraction,
145
+ instrument_map=instrument_map,
146
+ default_instrument_id=default_instrument_id,
147
+ group_ids=group_ids,
148
+ )
149
+ photometry.extend(actions.photometry)
150
+ comments.extend(actions.comments)
151
+ skipped += actions.skipped_rows
152
+ if redshift is None and actions.redshift is not None:
153
+ redshift = actions.redshift
154
+
155
+ # Dedup photometry (idempotent re-runs; overlapping reports).
156
+ seen: set[tuple[Any, ...]] = set()
157
+ deduped = []
158
+ for point in photometry:
159
+ key = _dedup_key(point)
160
+ if key not in seen:
161
+ seen.add(key)
162
+ deduped.append(point)
163
+
164
+ obj_id = deduped[0].obj_id if deduped else None
165
+ source: SourceUpsert | None = None
166
+ if obj_id is not None and localization is not None:
167
+ source = SourceUpsert(
168
+ id=obj_id, ra=localization.ra, dec=localization.dec, group_ids=group_ids
169
+ )
170
+
171
+ # Unique comments, order preserved.
172
+ seen_c: set[str] = set()
173
+ uniq_comments: list[str] = []
174
+ for comment in comments:
175
+ if comment not in seen_c:
176
+ seen_c.add(comment)
177
+ uniq_comments.append(comment)
178
+
179
+ return SkyPortalActions(
180
+ source=source,
181
+ photometry=deduped,
182
+ redshift=redshift,
183
+ comments=uniq_comments,
184
+ skipped_rows=skipped,
185
+ )
circex/bot/poster.py ADDED
@@ -0,0 +1,119 @@
1
+ """Turn SkyPortalActions into HTTP writes — or, by default, print them (dry-run).
2
+
3
+ Live posting requires BOTH an explicit `live=True` and a `token`; otherwise the
4
+ poster only renders the payloads it would send. This guards an outward-facing,
5
+ hard-to-reverse action (writing to a shared SkyPortal instance).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ import structlog
15
+
16
+ from circex.bot.skyportal_map import SkyPortalActions
17
+
18
+ log = structlog.get_logger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class SkyPortalPoster:
23
+ """Posts (or dry-runs) SkyPortalActions against a SkyPortal instance."""
24
+
25
+ base_url: str = "https://skyportal.example/api"
26
+ token: str | None = None
27
+ live: bool = False
28
+ timeout: float = 30.0
29
+ continue_on_error: bool = False # log + continue instead of raising (unattended use)
30
+
31
+ def post(self, actions: SkyPortalActions) -> list[dict[str, Any]]:
32
+ """Apply the actions. Returns the list of {method, path, payload} planned.
33
+
34
+ In dry-run (default) nothing is sent. With `live=True` and a token, each
35
+ planned request is executed in order: source upsert, photometry,
36
+ redshift patch, comments.
37
+ """
38
+ plan = self._plan(actions)
39
+ if not (self.live and self.token):
40
+ for req in plan:
41
+ log.info(
42
+ "skyportal_dryrun",
43
+ method=req["method"],
44
+ path=req["path"],
45
+ payload=req["payload"],
46
+ )
47
+ return plan
48
+ self._execute(plan)
49
+ return plan
50
+
51
+ def _plan(self, actions: SkyPortalActions) -> list[dict[str, Any]]:
52
+ plan: list[dict[str, Any]] = []
53
+ if actions.source is not None:
54
+ plan.append(
55
+ {"method": "POST", "path": "/sources", "payload": actions.source.to_payload()}
56
+ )
57
+ obj_id = actions.source.id
58
+ for point in actions.photometry:
59
+ plan.append(
60
+ {"method": "POST", "path": "/photometry", "payload": point.to_payload()}
61
+ )
62
+ if actions.redshift is not None:
63
+ z, z_err = actions.redshift
64
+ plan.append(
65
+ {
66
+ "method": "PATCH",
67
+ "path": f"/sources/{obj_id}",
68
+ "payload": {"redshift": z, "redshift_error": z_err},
69
+ }
70
+ )
71
+ for text in actions.comments:
72
+ plan.append(
73
+ {
74
+ "method": "POST",
75
+ "path": f"/sources/{obj_id}/comments",
76
+ "payload": {"text": text},
77
+ }
78
+ )
79
+ return plan
80
+
81
+ def _execute(self, plan: list[dict[str, Any]]) -> None:
82
+ import requests # local import: only needed for live posting
83
+
84
+ headers = {"Authorization": f"token {self.token}", "Content-Type": "application/json"}
85
+ for req in plan:
86
+ url = self.base_url.rstrip("/") + req["path"]
87
+ try:
88
+ resp = requests.request(
89
+ req["method"], url, headers=headers,
90
+ data=json.dumps(req["payload"]), timeout=self.timeout,
91
+ )
92
+ resp.raise_for_status()
93
+ # SkyPortal also signals failure as HTTP 200 with status="error".
94
+ body = resp.json()
95
+ if isinstance(body, dict) and body.get("status") == "error":
96
+ raise RuntimeError(str(body.get("message", "skyportal error"))[:200])
97
+ except Exception as exc:
98
+ if not self.continue_on_error:
99
+ raise
100
+ # Unattended: one bad request (e.g. a filter the instrument lacks)
101
+ # must not kill the stream.
102
+ log.warning(
103
+ "skyportal_post_failed",
104
+ method=req["method"], path=req["path"], error=str(exc)[:200],
105
+ )
106
+ continue
107
+ log.info(
108
+ "skyportal_posted",
109
+ method=req["method"], path=req["path"], status=resp.status_code,
110
+ )
111
+
112
+
113
+ def render_plan(plan: list[dict[str, Any]]) -> str:
114
+ """Human-readable dump of a planned request list (for CLI dry-run output)."""
115
+ lines: list[str] = []
116
+ for req in plan:
117
+ lines.append(f"{req['method']} {req['path']}")
118
+ lines.append(" " + json.dumps(req["payload"], ensure_ascii=False))
119
+ return "\n".join(lines)
@@ -0,0 +1,257 @@
1
+ """Map a CircularExtraction to SkyPortal API writes.
2
+
3
+ Pure functions, no network. `to_actions()` returns a SkyPortalActions bundle —
4
+ a source upsert, photometry points, an optional redshift patch, and comments —
5
+ shaped as the dicts SkyPortal's REST API expects. The poster turns these into
6
+ HTTP calls (or prints them in dry-run). See docs/design_skyportal_bot.md.
7
+
8
+ Everything the ICARE work added feeds in here: obs_mjd -> mjd, bandpass ->
9
+ filter, telescope_canonical -> instrument_id, is_detection, redshift bounds ->
10
+ comment, provenance -> altdata.note.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from circex.extract.regex.mag_table import (
20
+ infer_bandpass,
21
+ infer_mag_system,
22
+ normalize_filter,
23
+ )
24
+ from circex.schema import CircularExtraction, PhotometryExt
25
+
26
+ # mag_system (our enum) -> SkyPortal magsys (lowercase). STMag has no direct
27
+ # SkyPortal equivalent; map to the closest and flag it in a comment upstream.
28
+ _MAGSYS = {"AB": "ab", "Vega": "vega", "STMag": "ab"}
29
+
30
+
31
+ def _obj_id(extraction: CircularExtraction) -> str | None:
32
+ """SkyPortal source id from the event name (spaces removed). None if unnamed.
33
+
34
+ Prefers an AT/optical designation over a bare GRB/GW trigger when the
35
+ extraction carries a list (the optical name is what SkyPortal keys on).
36
+ """
37
+ if extraction.event is None or extraction.event.event_name is None:
38
+ return None
39
+ name = extraction.event.event_name
40
+ names = name if isinstance(name, list) else [name]
41
+ optical = [n for n in names if re.match(r"(?i)^(AT|SN)\s?\d", n)]
42
+ chosen = optical[0] if optical else (names[0] if names else None)
43
+ return re.sub(r"\s+", "", chosen) if chosen else None
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SourceUpsert:
48
+ """`POST /api/sources` payload."""
49
+
50
+ id: str
51
+ ra: float | None
52
+ dec: float | None
53
+ group_ids: list[int] = field(default_factory=list)
54
+
55
+ def to_payload(self) -> dict[str, Any]:
56
+ out: dict[str, Any] = {"id": self.id}
57
+ if self.ra is not None:
58
+ out["ra"] = self.ra
59
+ if self.dec is not None:
60
+ out["dec"] = self.dec
61
+ if self.group_ids:
62
+ out["group_ids"] = self.group_ids
63
+ return out
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class PhotometryPoint:
68
+ """`POST /api/photometry` payload for one row."""
69
+
70
+ obj_id: str
71
+ mjd: float
72
+ filter: str
73
+ magsys: str
74
+ instrument_id: int | None
75
+ mag: float | None
76
+ magerr: float | None
77
+ limiting_mag: float | None
78
+ altdata: dict[str, Any] = field(default_factory=dict)
79
+
80
+ def to_payload(self) -> dict[str, Any]:
81
+ out: dict[str, Any] = {
82
+ "obj_id": self.obj_id,
83
+ "mjd": self.mjd,
84
+ "filter": self.filter,
85
+ "magsys": self.magsys,
86
+ "mag": self.mag,
87
+ "magerr": self.magerr,
88
+ "limiting_mag": self.limiting_mag,
89
+ }
90
+ if self.instrument_id is not None:
91
+ out["instrument_id"] = self.instrument_id
92
+ if self.altdata:
93
+ out["altdata"] = self.altdata
94
+ return out
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class SkyPortalActions:
99
+ """Everything to post for one circular."""
100
+
101
+ source: SourceUpsert | None
102
+ photometry: list[PhotometryPoint]
103
+ redshift: tuple[float, float | None] | None # (z, z_err)
104
+ comments: list[str]
105
+ skipped_rows: int # photometry rows we could not post (no mjd/filter)
106
+
107
+
108
+ def _provenance_note(extraction: CircularExtraction, path: str) -> str | None:
109
+ span = extraction.provenance.get(path)
110
+ return f'{path}: "{span.snippet}"' if span is not None else None
111
+
112
+
113
+ def to_actions(
114
+ extraction: CircularExtraction,
115
+ *,
116
+ instrument_map: dict[str, int] | None = None,
117
+ default_instrument_id: int | None = None,
118
+ group_ids: list[int] | None = None,
119
+ ) -> SkyPortalActions:
120
+ """Build the SkyPortal write bundle for one extraction.
121
+
122
+ `instrument_map` maps `telescope_canonical` -> SkyPortal instrument_id
123
+ (ICARE's table). `default_instrument_id` is the generic GCN instrument used
124
+ when a telescope isn't in the map (matching ICARE's fall-back). SkyPortal's
125
+ photometry endpoint REQUIRES an instrument_id, so a row that resolves to
126
+ neither a mapped nor a default id cannot be posted — it becomes a comment.
127
+ """
128
+ instrument_map = instrument_map or {}
129
+ group_ids = group_ids or []
130
+
131
+ obj_id = _obj_id(extraction)
132
+ loc = extraction.localization
133
+ ra = loc.ra if loc is not None else None
134
+ dec = loc.dec if loc is not None else None
135
+
136
+ photometry: list[PhotometryPoint] = []
137
+ comments: list[str] = []
138
+ skipped = 0
139
+
140
+ # A NEW SkyPortal source requires ra/dec. A follow-up circular usually has no
141
+ # position (it lives in the discovery circular), so guard against emitting a
142
+ # positionless source-create that SkyPortal would reject with a 400.
143
+ source: SourceUpsert | None = None
144
+ if obj_id is not None and ra is not None and dec is not None:
145
+ source = SourceUpsert(id=obj_id, ra=ra, dec=dec, group_ids=group_ids)
146
+ elif obj_id is not None:
147
+ comments.append(
148
+ f"Source {obj_id} not created: no RA/Dec in this circular "
149
+ f"(position comes from the discovery circular)."
150
+ )
151
+
152
+ for idx, row in enumerate(extraction.photometry):
153
+ point = _row_to_point(extraction, obj_id, idx, row, instrument_map, default_instrument_id)
154
+ if point is None:
155
+ skipped += 1
156
+ else:
157
+ photometry.append(point)
158
+
159
+ # Redshift: scalar only. Bounds (in notes) become a comment, not a value.
160
+ redshift: tuple[float, float | None] | None = None
161
+ if extraction.redshift is not None and extraction.redshift.redshift is not None:
162
+ err = extraction.redshift.redshift_error
163
+ z_err = err if isinstance(err, float) else None
164
+ redshift = (extraction.redshift.redshift, z_err)
165
+ if (note := _provenance_note(extraction, "redshift")) is not None:
166
+ comments.append(f"Redshift z={extraction.redshift.redshift} from {note}")
167
+
168
+ # Bound redshifts and other extractor notes -> comment.
169
+ for note in extraction.extraction_meta.notes:
170
+ comments.append(f"Note (not posted as a value): {note}")
171
+
172
+ if skipped:
173
+ comments.append(
174
+ f"{skipped} photometry row(s) could not be posted "
175
+ f"(missing observation time, filter, or instrument_id); "
176
+ f"kept in the extraction only."
177
+ )
178
+
179
+ return SkyPortalActions(
180
+ source=source,
181
+ photometry=photometry,
182
+ redshift=redshift,
183
+ comments=comments,
184
+ skipped_rows=skipped,
185
+ )
186
+
187
+
188
+ def _effective_band(row: PhotometryExt) -> tuple[str | None, str]:
189
+ """Canonical (bandpass, magsys) for a row.
190
+
191
+ The deterministic filter crosswalk is authoritative for recognized filters
192
+ and OVERRIDES the extractor's values — an LLM may mislabel a band (e.g.
193
+ Mistral tagging Cousins "Rc" as sdssr/ab when it is bessellr/vega). Falls
194
+ back to the row's own bandpass/mag_system when the filter isn't recognized.
195
+ """
196
+ base = normalize_filter(row.filter) if row.filter else None
197
+ band = infer_bandpass(base) if base else None
198
+ if band is not None:
199
+ system = infer_mag_system(base) if base else None
200
+ return band, _MAGSYS.get(system or "", "ab")
201
+ return row.bandpass, _MAGSYS.get(row.mag_system or "", "ab")
202
+
203
+
204
+ def _row_to_point(
205
+ extraction: CircularExtraction,
206
+ obj_id: str | None,
207
+ idx: int,
208
+ row: PhotometryExt,
209
+ instrument_map: dict[str, int],
210
+ default_instrument_id: int | None,
211
+ ) -> PhotometryPoint | None:
212
+ """One photometry row -> a SkyPortal point, or None if unpostable.
213
+
214
+ A row needs an obj_id, an obs_mjd, a resolvable bandpass, AND an
215
+ instrument_id (mapped, or the generic default) — SkyPortal requires all of
216
+ these. Otherwise it cannot be posted.
217
+ """
218
+ band, magsys = _effective_band(row)
219
+ mapped = instrument_map.get(row.telescope_canonical) if row.telescope_canonical else None
220
+ instrument_id = mapped if mapped is not None else default_instrument_id
221
+ if obj_id is None or row.obs_mjd is None or band is None or instrument_id is None:
222
+ return None
223
+
224
+ # SkyPortal's photometry endpoint REQUIRES a non-null limiting_mag for
225
+ # mag-space points. Circulars often report a detection with no explicit
226
+ # per-point limit, so fall back to the detection mag itself — a conservative,
227
+ # truthful depth floor (the field was seen at least this faint) — and flag it.
228
+ limiting_mag = row.limiting_mag
229
+ limit_assumed = False
230
+ if limiting_mag is None:
231
+ if row.mag is None:
232
+ return None # neither a detection nor a stated limit — nothing to post
233
+ limiting_mag = row.mag
234
+ limit_assumed = True
235
+
236
+ altdata: dict[str, Any] = {}
237
+ if (note := _provenance_note(extraction, f"photometry[{idx}]")) is not None:
238
+ altdata["note"] = note
239
+ altdata["circex_circular_id"] = extraction.circular_id
240
+ if mapped is None:
241
+ # Fell back to the generic instrument; record what we actually saw.
242
+ altdata["instrument_fallback"] = True
243
+ if row.telescope:
244
+ altdata["telescope_as_written"] = row.telescope
245
+ if limit_assumed:
246
+ altdata["limiting_mag_assumed"] = True
247
+ return PhotometryPoint(
248
+ obj_id=obj_id,
249
+ mjd=row.obs_mjd,
250
+ filter=band,
251
+ magsys=magsys,
252
+ instrument_id=instrument_id,
253
+ mag=row.mag,
254
+ magerr=row.mag_error,
255
+ limiting_mag=limiting_mag,
256
+ altdata=altdata,
257
+ )
@@ -0,0 +1,5 @@
1
+ """LLM response cache."""
2
+
3
+ from circex.cache.llm import LLMCache, cache_key
4
+
5
+ __all__ = ["LLMCache", "cache_key"]