agronaut 1.0.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 (130) hide show
  1. agent/__init__.py +12 -0
  2. agent/calculator_ui.py +209 -0
  3. agent/classifier.py +231 -0
  4. agent/env.py +75 -0
  5. agent/facts.py +76 -0
  6. agent/llm.py +308 -0
  7. agent/observation_features.py +294 -0
  8. agent/optimizer_ui.py +100 -0
  9. agent/transcribe.py +83 -0
  10. agent/twin_ui.py +218 -0
  11. agent/vision.py +362 -0
  12. agronaut-1.0.0.data/data/share/agronaut/data/coefficient_sources.json +314 -0
  13. agronaut-1.0.0.data/data/share/agronaut/data/dataset_registry.json +176 -0
  14. agronaut-1.0.0.data/data/share/agronaut/data/empirical_envelope.json +108 -0
  15. agronaut-1.0.0.data/data/share/agronaut/data/price_book.json +122 -0
  16. agronaut-1.0.0.data/data/share/agronaut/data/reference_systems.json +60 -0
  17. agronaut-1.0.0.data/data/share/agronaut/data/tgc_calibration.json +159 -0
  18. agronaut-1.0.0.data/data/share/agronaut/data/twin_validation.json +171 -0
  19. agronaut-1.0.0.data/data/share/agronaut/knowledge/algae_control.md +36 -0
  20. agronaut-1.0.0.data/data/share/agronaut/knowledge/common_system_failures.md +45 -0
  21. agronaut-1.0.0.data/data/share/agronaut/knowledge/dissolved_oxygen_and_aeration.md +34 -0
  22. agronaut-1.0.0.data/data/share/agronaut/knowledge/economics_and_costs.md +95 -0
  23. agronaut-1.0.0.data/data/share/agronaut/knowledge/feasibility_and_business_case.md +46 -0
  24. agronaut-1.0.0.data/data/share/agronaut/knowledge/feed_and_feeding.md +40 -0
  25. agronaut-1.0.0.data/data/share/agronaut/knowledge/fish_disease_and_treatment.md +38 -0
  26. agronaut-1.0.0.data/data/share/agronaut/knowledge/fish_stress_diagnosis.md +21 -0
  27. agronaut-1.0.0.data/data/share/agronaut/knowledge/food_safety_and_hygiene.md +49 -0
  28. agronaut-1.0.0.data/data/share/agronaut/knowledge/nitrogen_cycle_and_cycling.md +44 -0
  29. agronaut-1.0.0.data/data/share/agronaut/knowledge/ph_and_alkalinity.md +41 -0
  30. agronaut-1.0.0.data/data/share/agronaut/knowledge/plant_deficiency_cheatsheet.md +21 -0
  31. agronaut-1.0.0.data/data/share/agronaut/knowledge/plant_nutrient_deficiencies.md +37 -0
  32. agronaut-1.0.0.data/data/share/agronaut/knowledge/plant_pests_and_ipm.md +42 -0
  33. agronaut-1.0.0.data/data/share/agronaut/knowledge/pump_sizing_basics.md +26 -0
  34. agronaut-1.0.0.data/data/share/agronaut/knowledge/regulations_and_permits.md +44 -0
  35. agronaut-1.0.0.data/data/share/agronaut/knowledge/solids_and_mineralization.md +38 -0
  36. agronaut-1.0.0.data/data/share/agronaut/knowledge/system_types_and_design.md +44 -0
  37. agronaut-1.0.0.data/data/share/agronaut/knowledge/temperature_and_climate_control.md +34 -0
  38. agronaut-1.0.0.data/data/share/agronaut/knowledge/water_quality_ranges.md +14 -0
  39. agronaut-1.0.0.data/data/share/agronaut/knowledge/water_source_and_treatment.md +36 -0
  40. agronaut-1.0.0.data/data/share/agronaut/knowledge/winter_cold_season_operation.md +48 -0
  41. agronaut-1.0.0.data/data/share/agronaut/urls.txt +64 -0
  42. agronaut-1.0.0.dist-info/METADATA +861 -0
  43. agronaut-1.0.0.dist-info/RECORD +130 -0
  44. agronaut-1.0.0.dist-info/WHEEL +5 -0
  45. agronaut-1.0.0.dist-info/entry_points.txt +2 -0
  46. agronaut-1.0.0.dist-info/licenses/LICENSE +21 -0
  47. agronaut-1.0.0.dist-info/top_level.txt +9 -0
  48. agronaut_agent/__init__.py +6 -0
  49. agronaut_agent/analytics.py +215 -0
  50. agronaut_agent/channels/__init__.py +1 -0
  51. agronaut_agent/channels/base.py +61 -0
  52. agronaut_agent/channels/commands.py +210 -0
  53. agronaut_agent/channels/repl.py +35 -0
  54. agronaut_agent/channels/telegram_adapter.py +450 -0
  55. agronaut_agent/channels/whatsapp_adapter.py +385 -0
  56. agronaut_agent/cli.py +182 -0
  57. agronaut_agent/core.py +947 -0
  58. agronaut_agent/memory_extract.py +64 -0
  59. agronaut_agent/paths.py +84 -0
  60. agronaut_agent/profile.py +180 -0
  61. agronaut_agent/rag.py +589 -0
  62. agronaut_agent/review.py +63 -0
  63. agronaut_agent/runtime.py +156 -0
  64. agronaut_agent/semantic.py +117 -0
  65. agronaut_agent/serialize.py +165 -0
  66. agronaut_agent/setup_wizard.py +253 -0
  67. agronaut_agent/store.py +666 -0
  68. agronaut_agent/tools.py +1661 -0
  69. agronaut_agent/twin_view.py +123 -0
  70. agronaut_agent/whatsapp_doctor.py +234 -0
  71. app.py +218 -0
  72. aqua_model/__init__.py +109 -0
  73. aqua_model/advisory.py +607 -0
  74. aqua_model/business.py +342 -0
  75. aqua_model/calibration.py +290 -0
  76. aqua_model/climate.py +119 -0
  77. aqua_model/coefficients.py +172 -0
  78. aqua_model/costing.py +260 -0
  79. aqua_model/cropgrowth.py +131 -0
  80. aqua_model/crops.py +319 -0
  81. aqua_model/datasets.py +330 -0
  82. aqua_model/fishgrowth.py +201 -0
  83. aqua_model/flowsheet.py +295 -0
  84. aqua_model/hydraulics.py +411 -0
  85. aqua_model/hydroponics.py +167 -0
  86. aqua_model/layout.py +346 -0
  87. aqua_model/logging_schema.py +98 -0
  88. aqua_model/massbalance.py +144 -0
  89. aqua_model/mirror.py +137 -0
  90. aqua_model/optimizer.py +202 -0
  91. aqua_model/overrides.py +73 -0
  92. aqua_model/pilot.py +108 -0
  93. aqua_model/production.py +338 -0
  94. aqua_model/reference_data.py +65 -0
  95. aqua_model/reference_systems.py +55 -0
  96. aqua_model/report.py +119 -0
  97. aqua_model/scenario.py +245 -0
  98. aqua_model/scene3d.py +348 -0
  99. aqua_model/schematic.py +240 -0
  100. aqua_model/sensor_qc.py +144 -0
  101. aqua_model/sizing.py +262 -0
  102. aqua_model/species.py +165 -0
  103. aqua_model/system_types.py +108 -0
  104. aqua_model/triage.py +611 -0
  105. aqua_model/twin.py +343 -0
  106. aqua_model/types.py +131 -0
  107. aqua_model/validate.py +219 -0
  108. aqua_model/validation_status.py +95 -0
  109. bot.py +28 -0
  110. scripts/__init__.py +0 -0
  111. scripts/calibrate_tgc.py +175 -0
  112. scripts/check_vision_corpus.py +49 -0
  113. scripts/corpus_report.py +377 -0
  114. scripts/data_registry.py +206 -0
  115. scripts/faithfulness_eval.py +361 -0
  116. scripts/fetch_aquaponics_data.py +65 -0
  117. scripts/fetch_climate.py +128 -0
  118. scripts/fetch_faostat_prices.py +110 -0
  119. scripts/render_3d.py +158 -0
  120. scripts/retrieval_eval.py +248 -0
  121. scripts/retrieval_sweep.py +273 -0
  122. scripts/safety_eval.py +134 -0
  123. scripts/validate_telegram_flows.py +96 -0
  124. scripts/validate_twin.py +309 -0
  125. scripts/vision_eval.py +128 -0
  126. skills/__init__.py +0 -0
  127. skills/aquaponics_engineer/__init__.py +0 -0
  128. skills/aquaponics_engineer/cli.py +140 -0
  129. srcs/chatbot.py +1923 -0
  130. whatsapp.py +91 -0
agent/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """agent — the LLM-facing layer that wraps the deterministic aqua_model core.
2
+
3
+ Dependency rule (eng review): this layer may import from `aqua_model`, never the reverse.
4
+ `aqua_model` stays pure and Ollama-free; everything that touches the LLM or the UI lives here.
5
+ """
6
+
7
+ # Load project-root .env (HF_TOKEN, etc.) as early as possible. Importing any agent
8
+ # submodule — the UI (calculator_ui/optimizer_ui) or the chat layer (agent.llm) —
9
+ # triggers this, so secrets land in os.environ before any backend is built.
10
+ from agent.env import load_env as _load_env
11
+
12
+ _load_env()
agent/calculator_ui.py ADDED
@@ -0,0 +1,209 @@
1
+ """Streamlit view for the M1 design calculator.
2
+
3
+ A structured form (the right UI for fixed engineering inputs) → the validated trust gate
4
+ → size_system() → results + the funder-facing report as a download. No LLM in this path:
5
+ every number shown is traceable to a cited coefficient.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import streamlit as st
11
+
12
+ from agent import facts
13
+ from aqua_model import size_system
14
+ from aqua_model.report import to_markdown
15
+
16
+
17
+ def _render_channel_verdict(label: str, c: dict, mode: str) -> None:
18
+ target, dne, median = c["target_band"], c["do_not_exceed_band"], c["median"]
19
+ position = c["median_position"]
20
+
21
+ if position == "within target band":
22
+ icon, verdict = "✅", "real ponds sit inside your target band"
23
+ elif dne[0] <= median <= dne[1]:
24
+ icon = "⚠️"
25
+ verdict = f"real ponds run {position.replace(' target band', '')} target (still within safe limits)"
26
+ else:
27
+ icon = "🚩"
28
+ verdict = f"real ponds run {position.replace(' target band', '')} target — outside safe limits"
29
+
30
+ st.markdown(f"**{icon} {label}** — {verdict}")
31
+ line = f"real median **{median}**"
32
+ if mode == "summary":
33
+ line += f" (p5–p95: {c['p5']}–{c['p95']})"
34
+ line += f" · your target {target[0]}–{target[1]} · safe {dne[0]}–{dne[1]}"
35
+ if mode == "full":
36
+ line += (
37
+ f" · **{c['frac_in_target'] * 100:.0f}%** of readings in target, "
38
+ f"**{c['frac_in_do_not_exceed'] * 100:.0f}%** within safe limits"
39
+ )
40
+ st.caption(line)
41
+
42
+
43
+ def _render_reality_check(operating_envelope: dict) -> None:
44
+ """Compare this design's envelope against real-pond readings (open dataset)."""
45
+ from aqua_model import datasets
46
+
47
+ reality = datasets.envelope_reality_check(operating_envelope)
48
+ with st.expander("Reality check — your envelope vs. real ponds"):
49
+ if reality is None:
50
+ st.caption(
51
+ "Open dataset not loaded. Run `python scripts/fetch_aquaponics_data.py` to "
52
+ "compare against ~233k readings from four real aquaponics ponds."
53
+ )
54
+ return
55
+ n = reality.get("n_readings")
56
+ scope = f"~{n:,} readings, " if n else ""
57
+ st.caption(
58
+ f"Compared against {scope}{reality['source']}. Only temperature and pH are "
59
+ "cross-checked — turbidity and ammonia sensors in this dataset are saturated and "
60
+ "not trustworthy."
61
+ )
62
+ labels = {"water_temp_c": "Water temperature (°C)", "ph": "pH"}
63
+ for channel, c in reality["channels"].items():
64
+ _render_channel_verdict(labels.get(channel, channel), c, reality["mode"])
65
+
66
+
67
+ def _render_coefficient_sources(species: str, crop: str) -> None:
68
+ """Show the chosen species/crop sizing coefficients against published empirical ranges."""
69
+ from aqua_model import calibration as cal
70
+
71
+ relevant = [c for c in cal.all_calibrations() if c.key.split(".")[0] in (species, crop)]
72
+ if not relevant:
73
+ return
74
+ n_out = sum(not c.within for c in relevant)
75
+ title = "Sizing coefficients vs. published ranges"
76
+ if n_out:
77
+ title += f" ⚠️ {n_out} outside range"
78
+ with st.expander(title):
79
+ st.caption(
80
+ "Seed values pinned to peer-reviewed empirical ranges. ✅ in range · ⚠️ outside — "
81
+ "calibrate against your own system before building."
82
+ )
83
+ for c in relevant:
84
+ icon = "✅" if c.within else "⚠️"
85
+ st.markdown(
86
+ f"**{icon} {c.label}** — seed **{c.seed} {c.unit}** · "
87
+ f"published **{c.emp_low}–{c.emp_high}** · _{c.verdict}_"
88
+ )
89
+ st.caption(c.note + " \nSources: " + "; ".join(c.sources))
90
+
91
+
92
+ def render_calculator() -> None:
93
+ st.subheader("Design Calculator")
94
+ st.caption(
95
+ "Size one system from fixed inputs. Pure deterministic model — no AI guessing; "
96
+ "every number is traceable to a cited coefficient."
97
+ )
98
+
99
+ with st.form("design_form"):
100
+ col1, col2 = st.columns(2)
101
+ with col1:
102
+ species = st.selectbox("Fish species", facts.available_species())
103
+ crops = st.multiselect(
104
+ "Crops", facts.available_crops(), default=["lettuce"],
105
+ help="Pick 2+ for a MIXED BED sharing one system — the grow area is split "
106
+ "evenly, and the model warns if the crops can't share one water.")
107
+ site = st.text_input("Site / project name (optional)", "")
108
+ with col2:
109
+ grow_area = st.number_input("Grow area (m²)", min_value=0.1, value=6.0, step=0.5)
110
+ temperature = st.number_input("Mean water temp (°C)", min_value=0.0, max_value=45.0, value=26.0, step=0.5)
111
+ water_budget = st.number_input("Water budget (L/day)", min_value=0.0, value=200.0, step=10.0)
112
+ system_type = st.selectbox(
113
+ "Growing method", facts.available_system_types(),
114
+ help="raft/DWC (forgiving, more water), NFT (light, low water, needs reliable "
115
+ "power), or media bed (robust, also biofilters).")
116
+ submitted = st.form_submit_button("Size system", use_container_width=True)
117
+
118
+ if not submitted:
119
+ st.info("Set your inputs and press **Size system**.")
120
+ return
121
+
122
+ if not crops:
123
+ st.error("Pick at least one crop.")
124
+ return
125
+
126
+ try:
127
+ if len(crops) > 1:
128
+ design = facts.design_from_form(
129
+ fish_species=species, crop=None, grow_area_m2=None,
130
+ temperature_c=temperature, water_budget_lpd=water_budget,
131
+ system_type=system_type,
132
+ crop_plan=facts.split_area_evenly(crops, grow_area),
133
+ )
134
+ else:
135
+ design = facts.design_from_form(
136
+ fish_species=species, crop=crops[0], grow_area_m2=grow_area,
137
+ temperature_c=temperature, water_budget_lpd=water_budget,
138
+ system_type=system_type,
139
+ )
140
+ except facts.ValidationError as err:
141
+ st.error("Invalid inputs:\n" + "\n".join(f"- {e}" for e in err.errors))
142
+ return
143
+
144
+ out = size_system(design)
145
+
146
+ if out.feasible:
147
+ st.success("Feasible design.")
148
+ else:
149
+ st.warning(f"Not feasible — binding constraint: **{out.binding_constraint}**.")
150
+
151
+ if out.crop_plan:
152
+ st.caption("Mixed bed (crops sharing the water): "
153
+ + ", ".join(f"{p['crop']} {p['area_m2']:g} m²" for p in out.crop_plan))
154
+
155
+ for w in out.warnings:
156
+ st.warning(w)
157
+
158
+ m1, m2, m3 = st.columns(3)
159
+ m1.metric("Feed", f"{out.feed_g_per_day:g} g/day")
160
+ m2.metric("Fish", f"{out.fish_count} head")
161
+ m3.metric("Biomass", f"{out.fish_biomass_kg:g} kg")
162
+ m4, m5, m6 = st.columns(3)
163
+ m4.metric("System volume", f"{out.system_volume_l:g} L")
164
+ m5.metric("Pump", f"{out.pump_turnover_lph:g} L/h")
165
+ m6.metric("Makeup water", f"{out.makeup_water_lpd:g} L/day")
166
+ if out.footprint_ratio != 1.0:
167
+ st.metric("Floor footprint", f"{out.footprint_m2:g} m²",
168
+ help=f"{out.grow_area_m2:g} m² of growing area packed onto the floor "
169
+ f"(~{out.footprint_ratio:g}× via vertical towers).")
170
+
171
+ import base64
172
+
173
+ from aqua_model.schematic import to_svg
174
+ svg = to_svg(out)
175
+ st.subheader("System schematic")
176
+ # SVG rendered as a data-URI <img> (st.image's PIL path can't parse raw SVG).
177
+ b64 = base64.b64encode(svg.encode("utf-8")).decode("ascii")
178
+ st.markdown(f'<img src="data:image/svg+xml;base64,{b64}" style="max-width:100%"/>',
179
+ unsafe_allow_html=True)
180
+ st.download_button("Download schematic (SVG)", data=svg,
181
+ file_name="agronaut_schematic.svg", mime="image/svg+xml")
182
+
183
+ with st.expander("Bill of materials"):
184
+ st.table(out.bill_of_materials)
185
+ with st.expander("Operating envelope"):
186
+ st.json(out.operating_envelope)
187
+ _render_reality_check(out.operating_envelope)
188
+ with st.expander("Nitrogen consistency check"):
189
+ st.json(out.nitrogen_check)
190
+ with st.expander("What is NOT modeled (read before building)"):
191
+ for n in out.not_modeled:
192
+ st.markdown(f"- {n}")
193
+ with st.expander("Coefficients used (auditable)"):
194
+ st.table([
195
+ {"name": c.name, "value": c.value, "range": f"{c.low}–{c.high}", "unit": c.unit, "source": c.source}
196
+ for c in out.coefficients_used
197
+ ])
198
+ # Coefficient-source panels for each crop in the design (one for a single crop).
199
+ for c in (crops or []):
200
+ _render_coefficient_sources(species, c)
201
+
202
+ report_md = to_markdown(design, out, site=site or None)
203
+ st.download_button(
204
+ "Download design report (Markdown)",
205
+ data=report_md,
206
+ file_name=f"aquaponics-design-{(site or 'system').strip().replace(' ', '-').lower()}.md",
207
+ mime="text/markdown",
208
+ use_container_width=True,
209
+ )
agent/classifier.py ADDED
@@ -0,0 +1,231 @@
1
+ """Pluggable image-classifier backend — an extra FEATURE source, never a verdict source.
2
+
3
+ A specialist classifier (a PlantVillage-class CNN, say) beats a general vision model at
4
+ fine-grained leaf disease. The temptation is to let its label be the answer. That would
5
+ undo the whole point of the vision path: a label like `Tomato___Late_blight` is a diagnosis
6
+ with no citation, no discriminating check, and no statement of what it cannot see.
7
+
8
+ So the classifier is wired in as a **third source of categorical features**, alongside the
9
+ prose extractor:
10
+
11
+ photo ──▶ VLM ──────────▶ prose ──▶ features ─┐
12
+ └──▶ classifier ──▶ label ──▶ features ───┴─▶ aqua_model.triage ──▶ cited differential
13
+
14
+ The label is mapped, by a fixed table, onto the SAME `ObservationFeatures` vocabulary the
15
+ prose extractor uses. Consequences that matter:
16
+
17
+ * the differential's candidates stay cited to `knowledge/*.md` — a classifier can never
18
+ introduce an uncited candidate, no matter how confident it is;
19
+ * a wrong label can only select a different branch of a differential, exactly like a
20
+ misread adjective;
21
+ * the label itself is surfaced to the user as an unverified model suggestion, clearly
22
+ separated from the cited differential.
23
+
24
+ Provider-agnostic, mirroring `agent/llm.py` and `agent/vision.py`: select with
25
+ CLASSIFIER_PROVIDER / CLASSIFIER_MODEL. **No backend ships with Agronaut yet** — this is the
26
+ seam a contributor implements. `default_classifier()` returns None until one exists, so the
27
+ whole path is inert by default and nothing changes for existing deployments.
28
+
29
+ Adding a backend: see CONTRIBUTING.md ("Adding an image classifier").
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ import os
36
+ import re
37
+ from dataclasses import dataclass
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ # Below this, a label is discarded rather than fed forward. A low-confidence guess is worse
42
+ # than no guess: it adds a branch to the differential while carrying no real information.
43
+ MIN_CONFIDENCE = 0.60
44
+
45
+ DEFAULT_MODELS: dict[str, str] = {
46
+ # No backend is bundled yet. A contributor adding one registers its default here and a
47
+ # builder in _build_classifier_backend below. See issue "Plant-disease classifier".
48
+ }
49
+ SUPPORTED = tuple(DEFAULT_MODELS)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Prediction:
54
+ """One classifier output. `label` is the model's raw class name — kept verbatim so it can
55
+ be shown to the user as what the model actually said."""
56
+
57
+ label: str
58
+ confidence: float
59
+
60
+
61
+ # --- label → features -------------------------------------------------------------------
62
+ # The mapping is deliberately COARSE. We are not translating a diagnosis; we are extracting
63
+ # the visible evidence a diagnosis would have been based on, so the cited table can do the
64
+ # reasoning. `late blight` becomes "there are brown spots on the leaves", not "late blight".
65
+ #
66
+ # Keys are matched as substrings against a normalised label, so the same entry covers
67
+ # `Tomato___Late_blight`, `tomato late blight`, and `Late Blight (tomato)`.
68
+ _LABEL_FEATURES: tuple[tuple[str, dict], ...] = (
69
+ # nutrient pictures — the ones the cited table discriminates best
70
+ ("iron deficien", {"leaf_pattern": ["interveinal"], "colour": ["yellow"], "leaf_age": "new"}),
71
+ ("interveinal", {"leaf_pattern": ["interveinal"], "colour": ["yellow"]}),
72
+ ("nitrogen deficien", {"leaf_pattern": ["whole_pale"], "colour": ["yellow"], "leaf_age": "old"}),
73
+ ("potassium deficien", {"leaf_pattern": ["margin_scorch"], "colour": ["brown"], "leaf_age": "old"}),
74
+ ("calcium deficien", {"leaf_pattern": ["tip_burn"], "leaf_age": "new"}),
75
+ ("tip burn", {"leaf_pattern": ["tip_burn"], "leaf_age": "new"}),
76
+ ("blossom_end", {"leaf_pattern": ["tip_burn"], "leaf_age": "new"}),
77
+ ("blossom end", {"leaf_pattern": ["tip_burn"], "leaf_age": "new"}),
78
+ # fungal / bacterial leaf disease → visible evidence only
79
+ ("powdery_mildew", {"leaf_pattern": ["powder"], "colour": ["white"]}),
80
+ ("powdery mildew", {"leaf_pattern": ["powder"], "colour": ["white"]}),
81
+ ("downy_mildew", {"leaf_pattern": ["powder"], "colour": ["white"]}),
82
+ ("downy mildew", {"leaf_pattern": ["powder"], "colour": ["white"]}),
83
+ ("late_blight", {"leaf_pattern": ["spots"], "colour": ["brown"]}),
84
+ ("late blight", {"leaf_pattern": ["spots"], "colour": ["brown"]}),
85
+ ("early_blight", {"leaf_pattern": ["spots"], "colour": ["brown"]}),
86
+ ("early blight", {"leaf_pattern": ["spots"], "colour": ["brown"]}),
87
+ ("leaf_spot", {"leaf_pattern": ["spots"]}),
88
+ ("leaf spot", {"leaf_pattern": ["spots"]}),
89
+ ("leaf_mold", {"leaf_pattern": ["spots"], "colour": ["yellow"]}),
90
+ ("leaf mold", {"leaf_pattern": ["spots"], "colour": ["yellow"]}),
91
+ ("rust", {"leaf_pattern": ["spots"], "colour": ["brown"]}),
92
+ ("blight", {"leaf_pattern": ["spots"], "colour": ["brown"]}),
93
+ ("root_rot", {"root_state": "brown_slimy"}),
94
+ ("root rot", {"root_state": "brown_slimy"}),
95
+ ("pythium", {"root_state": "brown_slimy"}),
96
+ # pests
97
+ ("spider_mite", {"pests_visible": ["mites"], "leaf_pattern": ["stippled"]}),
98
+ ("spider mite", {"pests_visible": ["mites"], "leaf_pattern": ["stippled"]}),
99
+ ("aphid", {"pests_visible": ["aphids"]}),
100
+ ("whitefly", {"pests_visible": ["whiteflies"]}),
101
+ ("whiteflies", {"pests_visible": ["whiteflies"]}),
102
+ ("thrip", {"leaf_pattern": ["stippled"]}),
103
+ # fish
104
+ ("ich", {"fish_body": ["white_spots"], "subject": ["fish"]}),
105
+ ("white_spot", {"fish_body": ["white_spots"], "subject": ["fish"]}),
106
+ ("fin_rot", {"fish_body": ["frayed_fins"], "subject": ["fish"]}),
107
+ ("fin rot", {"fish_body": ["frayed_fins"], "subject": ["fish"]}),
108
+ ("saprolegnia", {"fish_body": ["cotton_tufts"], "subject": ["fish"]}),
109
+ ("columnaris", {"fish_body": ["lesion"], "subject": ["fish"]}),
110
+ # explicit healthy: carries no symptom, and must not invent one
111
+ ("healthy", {}),
112
+ )
113
+
114
+ _LEAF_SUBJECT_HINT = re.compile(r"leaf|leaves|plant|tomato|lettuce|potato|pepper|corn|grape|"
115
+ r"apple|cherry|peach|strawberr|squash|orange|blueberr|raspberr|"
116
+ r"soybean", re.IGNORECASE)
117
+ _FISH_SUBJECT_HINT = re.compile(r"fish|tilapia|catfish|carp|trout|koi", re.IGNORECASE)
118
+
119
+
120
+ def _normalise(label: str) -> str:
121
+ return re.sub(r"[_\-/]+", " ", str(label or "")).strip().lower()
122
+
123
+
124
+ def features_from_predictions(predictions) -> dict:
125
+ """Merge classifier labels into `ObservationFeatures` keyword arguments.
126
+
127
+ Only predictions at or above MIN_CONFIDENCE are considered, and only the coarse visible
128
+ evidence is extracted — never the diagnosis itself. Returns {} when nothing maps, which
129
+ leaves the differential to the prose extractor alone.
130
+
131
+ Pure and total: any iterable of Prediction-like objects in, a plain dict out."""
132
+ merged: dict = {}
133
+
134
+ def _add_seq(key: str, values) -> None:
135
+ cur = list(merged.get(key, []))
136
+ for v in values:
137
+ if v not in cur:
138
+ cur.append(v)
139
+ merged[key] = cur
140
+
141
+ for pred in predictions or ():
142
+ label = _normalise(getattr(pred, "label", ""))
143
+ try:
144
+ confidence = float(getattr(pred, "confidence", 0.0))
145
+ except (TypeError, ValueError):
146
+ continue
147
+ if not label or confidence < MIN_CONFIDENCE:
148
+ continue
149
+
150
+ # Subject comes from the label's own words, so a leaf class can't imply a fish.
151
+ if _FISH_SUBJECT_HINT.search(label):
152
+ _add_seq("subject", ["fish"])
153
+ elif _LEAF_SUBJECT_HINT.search(label):
154
+ _add_seq("subject", ["plant"])
155
+
156
+ for needle, feats in _LABEL_FEATURES:
157
+ if needle not in label:
158
+ continue
159
+ for key, value in feats.items():
160
+ if isinstance(value, list):
161
+ _add_seq(key, value)
162
+ else:
163
+ merged.setdefault(key, value) # first scalar wins; don't fight the prose
164
+ break # first (most specific) entry only
165
+ return merged
166
+
167
+
168
+ def describe_predictions(predictions) -> str:
169
+ """One line naming what the classifier said, for the agent turn.
170
+
171
+ Presented as an unverified suggestion and kept OUT of the cited differential, because a
172
+ classifier label has no source, no discriminating check, and no statement of its limits."""
173
+ kept = [p for p in (predictions or ())
174
+ if float(getattr(p, "confidence", 0.0) or 0.0) >= MIN_CONFIDENCE]
175
+ if not kept:
176
+ return ""
177
+ parts = ", ".join(f"{getattr(p, 'label', '?')} ({float(p.confidence):.0%})" for p in kept[:3])
178
+ return ("[An image classifier suggested: " + parts + ". That is an UNVERIFIED model label "
179
+ "with no source — it is NOT part of the cited differential below. Use it only as a "
180
+ "hint about what to look at, and never repeat it as a diagnosis.]")
181
+
182
+
183
+ def resolve(provider: str | None = None, model: str | None = None) -> tuple[str, str]:
184
+ provider = (provider or os.getenv("CLASSIFIER_PROVIDER") or "").strip().lower()
185
+ if not provider:
186
+ raise ValueError("No CLASSIFIER_PROVIDER set (no classifier backend ships yet).")
187
+ if provider not in SUPPORTED:
188
+ supported = ", ".join(SUPPORTED) or "none yet — see CONTRIBUTING.md"
189
+ raise ValueError(f"Unknown CLASSIFIER_PROVIDER {provider!r}. Supported: {supported}.")
190
+ model = model or os.getenv("CLASSIFIER_MODEL") or DEFAULT_MODELS[provider]
191
+ return provider, model
192
+
193
+
194
+ def _build_classifier_backend(provider: str, model: str):
195
+ """Return a callable(image_bytes) -> list[Prediction]. Lazy imports, so adding a backend
196
+ never adds a hard dependency for anyone not using it."""
197
+ raise ValueError( # pragma: no cover - no backend bundled yet
198
+ f"No classifier backend implemented for {provider!r}. See CONTRIBUTING.md "
199
+ "('Adding an image classifier') — this is a wanted contribution.")
200
+
201
+
202
+ def make_classifier(backend):
203
+ """Wrap a backend callable into classify(image_bytes) -> list[Prediction]."""
204
+ def _classify(image_bytes: bytes):
205
+ raw = backend(image_bytes) or []
206
+ out = []
207
+ for item in raw:
208
+ if isinstance(item, Prediction):
209
+ out.append(item)
210
+ continue
211
+ try: # accept (label, confidence) tuples too
212
+ label, confidence = item
213
+ out.append(Prediction(str(label), float(confidence)))
214
+ except Exception:
215
+ log.debug("classifier returned an unusable prediction: %r", item)
216
+ return out
217
+ return _classify
218
+
219
+
220
+ def default_classifier():
221
+ """A lazily-built classifier for the configured provider, or None when unavailable —
222
+ which is the normal case today, since no backend is bundled. Never raises."""
223
+ if os.getenv("AGRONAUT_CLASSIFIER", "").lower() in {"off", "0", "false"}:
224
+ return None
225
+ try:
226
+ provider, model = resolve()
227
+ backend = _build_classifier_backend(provider, model)
228
+ except Exception:
229
+ log.debug("classifier backend unavailable", exc_info=True)
230
+ return None
231
+ return make_classifier(backend)
agent/env.py ADDED
@@ -0,0 +1,75 @@
1
+ """Minimal .env loader — no third-party dependency.
2
+
3
+ Reads KEY=VALUE lines into os.environ. Real environment variables always win, so a shell
4
+ export or a systemd unit overrides the file rather than fighting it.
5
+
6
+ Where it looks, in order, first file wins:
7
+
8
+ 1. `$AGRONAUT_ENV_FILE` — an explicit path, for a service unit or a second deployment.
9
+ 2. `./.env` in the working directory — what someone expects after `cd ~/my-farm`.
10
+ 3. `~/.config/agronaut/.env` (or `$XDG_CONFIG_HOME`) — the per-user home for an
11
+ installed tool, and the answer for anyone who did `pip install agronaut`.
12
+ 4. The project root, for a source checkout.
13
+
14
+ Order 2 and 3 exist because of a real gap: the loader used to look only at the project
15
+ root, computed from this file's location. Inside an installed wheel that resolves to
16
+ `site-packages/.env`, a path nobody will ever create — so a user who installed from PyPI
17
+ had no way to configure a Telegram token at all except re-exporting variables in every new
18
+ shell. State already went to the right per-user place; configuration did not follow it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from pathlib import Path
25
+
26
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent
27
+
28
+
29
+ def user_config_dir() -> Path:
30
+ """Per-user config directory. Honours XDG, as the data and cache dirs already do."""
31
+ base = os.environ.get("XDG_CONFIG_HOME")
32
+ return (Path(base) if base else Path.home() / ".config") / "agronaut"
33
+
34
+
35
+ def candidates() -> list[Path]:
36
+ """Every place a .env may live, in precedence order."""
37
+ explicit = os.environ.get("AGRONAUT_ENV_FILE")
38
+ found = [Path(explicit)] if explicit else []
39
+ found.append(Path.cwd() / ".env")
40
+ found.append(user_config_dir() / ".env")
41
+ found.append(_PROJECT_ROOT / ".env")
42
+ # Preserve order while dropping repeats (a checkout run from its own root hits the
43
+ # working directory and the project root with the same path).
44
+ seen, out = set(), []
45
+ for p in found:
46
+ key = str(p)
47
+ if key not in seen:
48
+ seen.add(key)
49
+ out.append(p)
50
+ return out
51
+
52
+
53
+ def load_env(path: str | os.PathLike | None = None) -> Path | None:
54
+ """Load the first .env found and return it, or None when there is nothing to load."""
55
+ paths = [Path(path)] if path else candidates()
56
+ for env_path in paths:
57
+ try:
58
+ if not env_path.is_file():
59
+ continue
60
+ text = env_path.read_text()
61
+ except OSError:
62
+ # An unreadable candidate is not fatal: fall through to the next one rather
63
+ # than stop a bot from starting over a permissions problem on a stale path.
64
+ continue
65
+ for raw in text.splitlines():
66
+ line = raw.strip()
67
+ if not line or line.startswith("#") or "=" not in line:
68
+ continue
69
+ key, _, val = line.partition("=")
70
+ key = key.strip()
71
+ val = val.strip().strip('"').strip("'")
72
+ if key and key not in os.environ:
73
+ os.environ[key] = val
74
+ return env_path
75
+ return None
agent/facts.py ADDED
@@ -0,0 +1,76 @@
1
+ """Fact collection seam between the UI/LLM and the deterministic model.
2
+
3
+ For M1 the design calculator takes FIXED, structured inputs, so the safest "fact collection"
4
+ is a validated form (no free-text parsing, no LLM guessing engineering numbers). This module
5
+ exposes the option lists the UI needs and a single typed entry point into the trust zone.
6
+
7
+ The free-text parsers in `srcs/chatbot.py` (temperature/pH/species extraction for the
8
+ conversational troubleshooting flow) are extracted here in M3, when the orchestrator is
9
+ refactored. They are not needed by the M1 calculator and are intentionally left untouched.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from aqua_model.crops import CROPS
15
+ from aqua_model.species import SPECIES
16
+ from aqua_model.types import DesignInput
17
+ from aqua_model.validate import ValidationError, validate_design_input
18
+
19
+
20
+ def available_species() -> list[str]:
21
+ return sorted(SPECIES)
22
+
23
+
24
+ def available_crops() -> list[str]:
25
+ return sorted(CROPS)
26
+
27
+
28
+ def available_system_types() -> list[str]:
29
+ from aqua_model.system_types import SYSTEM_TYPES
30
+ return list(SYSTEM_TYPES)
31
+
32
+
33
+ def design_from_form(
34
+ fish_species: str,
35
+ crop: str,
36
+ grow_area_m2: float,
37
+ temperature_c: float,
38
+ water_budget_lpd: float,
39
+ source_water_note: str | None = None,
40
+ system_type: str = "raft",
41
+ crop_plan: list[dict] | None = None,
42
+ ) -> DesignInput:
43
+ """Validate structured form values into a DesignInput. Raises ValidationError on bad input.
44
+
45
+ This is the ONLY way the UI puts numbers into the model — the validation gate, reused.
46
+ Pass crop_plan (a list of {crop, area_m2}) for a mixed bed; then crop/grow_area_m2 are
47
+ derived from it and may be None.
48
+ """
49
+ return validate_design_input(
50
+ fish_species=fish_species,
51
+ crop=crop,
52
+ grow_area_m2=grow_area_m2,
53
+ temperature_c=temperature_c,
54
+ water_budget_lpd=water_budget_lpd,
55
+ source_water_note=source_water_note,
56
+ system_type=system_type,
57
+ crop_plan=crop_plan,
58
+ )
59
+
60
+
61
+ def split_area_evenly(crops: list[str], grow_area_m2: float) -> list[dict]:
62
+ """Turn a list of crops + a total area into an even-split crop_plan. The calculator form
63
+ can't collect per-crop areas reactively inside one submit, so it splits equally; the chat
64
+ agent (size_mixed_bed_aquaponics) takes explicit per-crop areas for finer control."""
65
+ per = round(grow_area_m2 / len(crops), 3)
66
+ return [{"crop": c, "area_m2": per} for c in crops]
67
+
68
+
69
+ __all__ = [
70
+ "available_species",
71
+ "available_crops",
72
+ "available_system_types",
73
+ "design_from_form",
74
+ "split_area_evenly",
75
+ "ValidationError",
76
+ ]