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.
- agent/__init__.py +12 -0
- agent/calculator_ui.py +209 -0
- agent/classifier.py +231 -0
- agent/env.py +75 -0
- agent/facts.py +76 -0
- agent/llm.py +308 -0
- agent/observation_features.py +294 -0
- agent/optimizer_ui.py +100 -0
- agent/transcribe.py +83 -0
- agent/twin_ui.py +218 -0
- agent/vision.py +362 -0
- agronaut-1.0.0.data/data/share/agronaut/data/coefficient_sources.json +314 -0
- agronaut-1.0.0.data/data/share/agronaut/data/dataset_registry.json +176 -0
- agronaut-1.0.0.data/data/share/agronaut/data/empirical_envelope.json +108 -0
- agronaut-1.0.0.data/data/share/agronaut/data/price_book.json +122 -0
- agronaut-1.0.0.data/data/share/agronaut/data/reference_systems.json +60 -0
- agronaut-1.0.0.data/data/share/agronaut/data/tgc_calibration.json +159 -0
- agronaut-1.0.0.data/data/share/agronaut/data/twin_validation.json +171 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/algae_control.md +36 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/common_system_failures.md +45 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/dissolved_oxygen_and_aeration.md +34 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/economics_and_costs.md +95 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/feasibility_and_business_case.md +46 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/feed_and_feeding.md +40 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/fish_disease_and_treatment.md +38 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/fish_stress_diagnosis.md +21 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/food_safety_and_hygiene.md +49 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/nitrogen_cycle_and_cycling.md +44 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/ph_and_alkalinity.md +41 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/plant_deficiency_cheatsheet.md +21 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/plant_nutrient_deficiencies.md +37 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/plant_pests_and_ipm.md +42 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/pump_sizing_basics.md +26 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/regulations_and_permits.md +44 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/solids_and_mineralization.md +38 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/system_types_and_design.md +44 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/temperature_and_climate_control.md +34 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/water_quality_ranges.md +14 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/water_source_and_treatment.md +36 -0
- agronaut-1.0.0.data/data/share/agronaut/knowledge/winter_cold_season_operation.md +48 -0
- agronaut-1.0.0.data/data/share/agronaut/urls.txt +64 -0
- agronaut-1.0.0.dist-info/METADATA +861 -0
- agronaut-1.0.0.dist-info/RECORD +130 -0
- agronaut-1.0.0.dist-info/WHEEL +5 -0
- agronaut-1.0.0.dist-info/entry_points.txt +2 -0
- agronaut-1.0.0.dist-info/licenses/LICENSE +21 -0
- agronaut-1.0.0.dist-info/top_level.txt +9 -0
- agronaut_agent/__init__.py +6 -0
- agronaut_agent/analytics.py +215 -0
- agronaut_agent/channels/__init__.py +1 -0
- agronaut_agent/channels/base.py +61 -0
- agronaut_agent/channels/commands.py +210 -0
- agronaut_agent/channels/repl.py +35 -0
- agronaut_agent/channels/telegram_adapter.py +450 -0
- agronaut_agent/channels/whatsapp_adapter.py +385 -0
- agronaut_agent/cli.py +182 -0
- agronaut_agent/core.py +947 -0
- agronaut_agent/memory_extract.py +64 -0
- agronaut_agent/paths.py +84 -0
- agronaut_agent/profile.py +180 -0
- agronaut_agent/rag.py +589 -0
- agronaut_agent/review.py +63 -0
- agronaut_agent/runtime.py +156 -0
- agronaut_agent/semantic.py +117 -0
- agronaut_agent/serialize.py +165 -0
- agronaut_agent/setup_wizard.py +253 -0
- agronaut_agent/store.py +666 -0
- agronaut_agent/tools.py +1661 -0
- agronaut_agent/twin_view.py +123 -0
- agronaut_agent/whatsapp_doctor.py +234 -0
- app.py +218 -0
- aqua_model/__init__.py +109 -0
- aqua_model/advisory.py +607 -0
- aqua_model/business.py +342 -0
- aqua_model/calibration.py +290 -0
- aqua_model/climate.py +119 -0
- aqua_model/coefficients.py +172 -0
- aqua_model/costing.py +260 -0
- aqua_model/cropgrowth.py +131 -0
- aqua_model/crops.py +319 -0
- aqua_model/datasets.py +330 -0
- aqua_model/fishgrowth.py +201 -0
- aqua_model/flowsheet.py +295 -0
- aqua_model/hydraulics.py +411 -0
- aqua_model/hydroponics.py +167 -0
- aqua_model/layout.py +346 -0
- aqua_model/logging_schema.py +98 -0
- aqua_model/massbalance.py +144 -0
- aqua_model/mirror.py +137 -0
- aqua_model/optimizer.py +202 -0
- aqua_model/overrides.py +73 -0
- aqua_model/pilot.py +108 -0
- aqua_model/production.py +338 -0
- aqua_model/reference_data.py +65 -0
- aqua_model/reference_systems.py +55 -0
- aqua_model/report.py +119 -0
- aqua_model/scenario.py +245 -0
- aqua_model/scene3d.py +348 -0
- aqua_model/schematic.py +240 -0
- aqua_model/sensor_qc.py +144 -0
- aqua_model/sizing.py +262 -0
- aqua_model/species.py +165 -0
- aqua_model/system_types.py +108 -0
- aqua_model/triage.py +611 -0
- aqua_model/twin.py +343 -0
- aqua_model/types.py +131 -0
- aqua_model/validate.py +219 -0
- aqua_model/validation_status.py +95 -0
- bot.py +28 -0
- scripts/__init__.py +0 -0
- scripts/calibrate_tgc.py +175 -0
- scripts/check_vision_corpus.py +49 -0
- scripts/corpus_report.py +377 -0
- scripts/data_registry.py +206 -0
- scripts/faithfulness_eval.py +361 -0
- scripts/fetch_aquaponics_data.py +65 -0
- scripts/fetch_climate.py +128 -0
- scripts/fetch_faostat_prices.py +110 -0
- scripts/render_3d.py +158 -0
- scripts/retrieval_eval.py +248 -0
- scripts/retrieval_sweep.py +273 -0
- scripts/safety_eval.py +134 -0
- scripts/validate_telegram_flows.py +96 -0
- scripts/validate_twin.py +309 -0
- scripts/vision_eval.py +128 -0
- skills/__init__.py +0 -0
- skills/aquaponics_engineer/__init__.py +0 -0
- skills/aquaponics_engineer/cli.py +140 -0
- srcs/chatbot.py +1923 -0
- 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
|
+
]
|