diff --git a/backend/app.py b/backend/app.py index 88f29ef..2c4b8aa 100644 --- a/backend/app.py +++ b/backend/app.py @@ -525,7 +525,7 @@ def result_literature_compare( raise HTTPException(status_code=404, detail=f"Unknown result_id: {result_id}") provider_ids = [str(item).strip() for item in (request.provider_ids or []) if str(item).strip()] - if not provider_ids and str(record.get("analysis_type") or "").upper() in {"XRD", "DSC", "DTA", "TGA"}: + if not provider_ids and str(record.get("analysis_type") or "").upper() in {"XRD", "DSC", "DTA", "TGA", "FTIR"}: provider_ids = ["openalex_like_provider"] try: providers, provider_scope = resolve_literature_providers( diff --git a/core/literature_compare.py b/core/literature_compare.py index b88d267..f095425 100644 --- a/core/literature_compare.py +++ b/core/literature_compare.py @@ -749,6 +749,7 @@ def _compare_generic_result_to_literature( normalized_user_documents = _normalize_user_document_sources(user_documents) query_count = 0 + executed_queries: list[str] = [] comparisons: list[dict[str, Any]] = [] citations_by_identity: dict[str, dict[str, Any]] = {} provider_request_ids: list[str] = [] @@ -774,6 +775,7 @@ def _compare_generic_result_to_literature( } claim_filters = _search_filters_for_claim(claim, filters) for query in build_claim_queries(claim): + executed_queries.append(query) for candidate in provider.search(query, filters=claim_filters): source_key = _search_result_identity(candidate) if not source_key: @@ -885,6 +887,7 @@ def _compare_generic_result_to_literature( if len(provider_scope) > 1 else (sorted(provider_result_sources)[0] if len(provider_result_sources) == 1 else _provider_result_source(provider, provider_scope=provider_scope)) ), + query_text=executed_queries[0] if executed_queries else "", query_count=query_count, source_count=len(all_sources_seen), citation_count=len(citations_by_identity), diff --git a/tests/conftest.py b/tests/conftest.py index 1078d40..d81f812 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,8 +7,10 @@ that tests are deterministic and require no external files. """ -import sys import os +import sys +import uuid +from pathlib import Path # Ensure the thermoanalyzer package root is importable regardless of where # pytest is invoked from. @@ -18,6 +20,9 @@ if _THERMOANALYZER_ROOT not in sys.path: sys.path.insert(0, _THERMOANALYZER_ROOT) +_LOCAL_TMP_ROOT = Path(_THERMOANALYZER_ROOT) / "pytest_temp" +_LOCAL_TMP_ROOT.mkdir(parents=True, exist_ok=True) + import numpy as np import pandas as pd import pytest @@ -100,6 +105,14 @@ def isolated_thermoanalyzer_home(monkeypatch, tmp_path): yield +@pytest.fixture +def tmp_path(): + """Use a workspace-local temp directory to avoid sandboxed system-temp permission issues.""" + path = _LOCAL_TMP_ROOT / f"case_{uuid.uuid4().hex[:8]}" + path.mkdir(parents=True, exist_ok=False) + return path + + @pytest.fixture(scope="session") def temperature_range(): """Uniform temperature grid from 30 to 300 degrees C with 500 points.""" diff --git a/tests/test_backend_details.py b/tests/test_backend_details.py index dc2a76a..2bdfa02 100644 --- a/tests/test_backend_details.py +++ b/tests/test_backend_details.py @@ -174,6 +174,46 @@ def _seed_thermal_result_store(analysis_type: str) -> tuple[ProjectStore, str, s return store, project_id, record["id"] +def _seed_spectral_result_store(analysis_type: str) -> tuple[ProjectStore, str, str]: + normalized = analysis_type.upper() + record = make_result_record( + result_id=f"{normalized.lower()}_demo", + analysis_type=normalized, + status="stable", + dataset_key=f"{normalized.lower()}_demo", + metadata={"sample_name": f"{normalized} Sample"}, + summary={ + "sample_name": f"{normalized} Sample", + "match_status": "no_match", + "candidate_count": 1, + "top_match_name": "Cellulose-like pattern", + "top_match_score": 0.41, + "confidence_band": "low", + }, + rows=[ + { + "rank": 1, + "candidate_name": "Cellulose-like pattern", + "normalized_score": 0.41, + "confidence_band": "low", + } + ], + scientific_context={ + "scientific_claims": [ + { + "id": "C1", + "claim": f"The {normalized} result suggests a qualitative band-pattern interpretation that remains non-confirmatory.", + } + ], + "uncertainty_assessment": {"overall_confidence": "low", "items": ["Interpretation remains qualitative."]}, + }, + validation={"status": "pass", "warnings": [], "issues": []}, + ) + store = ProjectStore() + project_id = store.put(normalize_workspace_state({"results": {record["id"]: record}})) + return store, project_id, record["id"] + + def _headers() -> dict[str, str]: return {"X-TA-Token": "details-token"} @@ -425,6 +465,23 @@ def test_result_literature_compare_endpoint_defaults_live_provider_for_thermal_r assert payload["literature_context"]["query_text"] +def test_result_literature_compare_endpoint_defaults_live_provider_for_ftir_results(): + store, project_id, result_id = _seed_spectral_result_store("FTIR") + client = TestClient(create_app(api_token="details-token", store=store)) + + response = client.post( + f"/workspace/{project_id}/results/{result_id}/literature/compare", + headers=_headers(), + json={"persist": True}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["literature_context"]["provider_scope"] == ["openalex_like_provider"] + assert payload["literature_context"]["analysis_type"] == "FTIR" + assert payload["literature_context"]["query_text"] + + def test_result_literature_compare_endpoint_validates_typed_user_documents(): store, project_id, result_id = _seed_xrd_result_store() client = TestClient(create_app(api_token="details-token", store=store)) diff --git a/tests/test_literature_compare_panel.py b/tests/test_literature_compare_panel.py index d192262..3dff1e8 100644 --- a/tests/test_literature_compare_panel.py +++ b/tests/test_literature_compare_panel.py @@ -7,6 +7,8 @@ from core.chemical_formula_formatting import format_chemical_formula_text from ui.components import literature_compare_panel +_REPO_ROOT = Path(__file__).resolve().parents[1] + class _FakeResponse: def __init__(self, status_code: int, payload: dict): @@ -268,6 +270,15 @@ def test_default_compare_request_uses_real_provider_for_thermal_modalities(): assert payload["filters"]["allow_fixture_fallback"] is False +def test_default_compare_request_uses_real_provider_for_ftir(): + payload = literature_compare_panel._default_compare_request(current_record={"analysis_type": "FTIR"}) + + assert payload["provider_ids"] == ["openalex_like_provider"] + assert payload["max_claims"] == 2 + assert payload["filters"]["analysis_type"] == "FTIR" + assert payload["filters"]["allow_fixture_fallback"] is False + + def test_render_literature_sections_renders_xrd_candidate_summary_before_paper_cards(monkeypatch): captions: list[str] = [] markdowns: list[str] = [] @@ -1147,16 +1158,24 @@ def test_render_literature_compare_panel_updates_session_state_on_success(monkey def test_xrd_page_results_summary_uses_literature_compare_panel(): - source = Path("C:/MaterialScope/ui/xrd_page.py").read_text(encoding="utf-8") + source = (_REPO_ROOT / "ui" / "xrd_page.py").read_text(encoding="utf-8") assert "render_literature_compare_panel(" in source def test_thermal_pages_results_summary_use_literature_compare_panel(): for path in ( - "C:/MaterialScope/ui/dsc_page.py", - "C:/MaterialScope/ui/dta_page.py", - "C:/MaterialScope/ui/tga_page.py", + _REPO_ROOT / "ui" / "dsc_page.py", + _REPO_ROOT / "ui" / "dta_page.py", + _REPO_ROOT / "ui" / "tga_page.py", ): source = Path(path).read_text(encoding="utf-8") assert "render_literature_compare_panel(" in source + + +def test_ftir_results_summary_uses_literature_compare_panel(): + source = (_REPO_ROOT / "ui" / "spectral_page.py").read_text(encoding="utf-8") + + assert "_render_literature_compare_if_supported(" in source + assert "_LITERATURE_COMPARE_ENABLED_TYPES = {\"FTIR\"}" in source + assert "render_literature_compare_panel(" in source diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py index fbceebe..4962d54 100644 --- a/tests/test_report_generator.py +++ b/tests/test_report_generator.py @@ -643,6 +643,69 @@ def test_generate_docx_report_renders_ftir_no_match_caution_fields(): assert "cloud_search" in xml +def test_generate_docx_report_renders_ftir_literature_sections(): + ftir_record, ftir_dataset = _make_ftir_no_match_record() + ftir_record["literature_context"] = { + "analysis_type": "FTIR", + "provider_scope": ["openalex_like_provider"], + "provider_request_ids": ["openalex_req_ftir_001"], + "query_text": "\"cellulose\" FTIR band assignment qualitative interpretation", + "query_rationale": "The FTIR literature search is centered on the leading band-pattern interpretation.", + "real_literature_available": True, + "restricted_content_used": False, + } + ftir_record["literature_claims"] = [ + { + "claim_id": "C1", + "claim_text": "The FTIR result suggests a cellulose-like band pattern that remains qualitative.", + } + ] + ftir_record["literature_comparisons"] = [ + { + "claim_id": "C1", + "claim_text": "The FTIR result suggests a cellulose-like band pattern that remains qualitative.", + "support_label": "related_but_inconclusive", + "confidence": "low", + "rationale": "Accessible literature discusses a similar FTIR band pattern, but the assignment remains non-confirmatory.", + "citation_ids": ["ref_ftir_001"], + } + ] + ftir_record["citations"] = [ + { + "citation_id": "ref_ftir_001", + "title": "Cellulose FTIR band assignments", + "authors": ["A. Author"], + "journal": "Journal of Spectroscopy", + "year": 2024, + "doi": "10.1000/ftir-cellulose", + "url": "https://doi.org/10.1000/ftir-cellulose", + "access_class": "open_access_full_text", + "available_fields": ["metadata", "abstract", "oa_full_text"], + "source_license_note": "open_access", + "provenance": { + "provider_id": "openalex_like_provider", + "request_id": "openalex_req_ftir_001", + "result_source": "openalex_api", + "provider_scope": ["openalex_like_provider"], + "provider_request_ids": ["openalex_req_ftir_001"], + }, + } + ] + + docx_bytes = generate_docx_report( + results={ftir_record["id"]: ftir_record}, + datasets={"synthetic_ftir": ftir_dataset}, + ) + + with zipfile.ZipFile(io.BytesIO(docx_bytes), "r") as archive: + xml = archive.read("word/document.xml").decode("utf-8") + + assert "Literature Comparison" in xml + assert "Supporting References" in xml + assert "Cellulose FTIR band assignments" in xml + assert "Recommended Follow-Up Literature Checks" in xml + + def test_generate_docx_report_renders_xrd_no_match_caution_fields(): xrd_record, xrd_dataset = _make_xrd_no_match_record() docx_bytes = generate_docx_report( diff --git a/tests/test_spectral_page.py b/tests/test_spectral_page.py new file mode 100644 index 0000000..c842353 --- /dev/null +++ b/tests/test_spectral_page.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from ui import spectral_page + + +def test_render_literature_compare_if_supported_runs_for_ftir(monkeypatch): + captions: list[str] = [] + divider_calls: list[bool] = [] + log_calls: list[tuple[tuple, dict]] = [] + + monkeypatch.setattr( + spectral_page, + "st", + SimpleNamespace( + divider=lambda: divider_calls.append(True), + caption=lambda text: captions.append(str(text)), + session_state={"lang": "en"}, + ), + ) + monkeypatch.setattr(spectral_page, "t", lambda key: key) + monkeypatch.setattr(spectral_page, "_log_event", lambda *args, **kwargs: log_calls.append((args, kwargs))) + monkeypatch.setattr( + spectral_page, + "render_literature_compare_panel", + lambda **kwargs: ( + {**dict(kwargs["record"] or {}), "literature_context": {"comparison_run_id": "litcmp_ftir_001"}}, + {"status": "success"}, + ), + ) + + updated = spectral_page._render_literature_compare_if_supported( + analysis_type="FTIR", + selected_key="demo", + record={"id": "ftir_demo"}, + title_key="ftir.title", + ) + + assert divider_calls == [True] + assert captions + assert updated["literature_context"]["comparison_run_id"] == "litcmp_ftir_001" + assert log_calls diff --git a/ui/components/literature_compare_panel.py b/ui/components/literature_compare_panel.py index dc6d0b4..97bacc2 100644 --- a/ui/components/literature_compare_panel.py +++ b/ui/components/literature_compare_panel.py @@ -871,7 +871,7 @@ def _default_compare_request( ) -> dict[str, Any]: payload = copy.deepcopy(DEFAULT_LITERATURE_COMPARE_REQUEST) analysis_type = _clean_text((current_record or {}).get("analysis_type")).upper() - if analysis_type in {"XRD", "DSC", "DTA", "TGA"}: + if analysis_type in {"XRD", "DSC", "DTA", "TGA", "FTIR"}: payload["filters"]["analysis_type"] = analysis_type or "XRD" else: payload = { diff --git a/ui/spectral_page.py b/ui/spectral_page.py index 43d2bb8..6a46820 100644 --- a/ui/spectral_page.py +++ b/ui/spectral_page.py @@ -20,6 +20,7 @@ from core.validation import validate_thermal_dataset from ui.components.chrome import render_page_header from ui.components.history_tracker import _log_event +from ui.components.literature_compare_panel import render_literature_compare_panel from ui.components.preset_manager import render_processing_preset_panel, seed_pending_workflow_template from ui.components.plot_builder import ( apply_plot_display_settings, @@ -66,6 +67,8 @@ }, } +_LITERATURE_COMPARE_ENABLED_TYPES = {"FTIR"} + def _get_spectral_datasets(analysis_type: str): datasets = st.session_state.get("datasets", {}) @@ -372,6 +375,43 @@ def _seed_spectral_processing_defaults(token: str, processing, workflow_template return seeded +def _render_literature_compare_if_supported( + *, + analysis_type: str, + selected_key: str, + record: dict | None, + title_key: str, +) -> dict | None: + token = str(analysis_type or "").upper() + if token not in _LITERATURE_COMPARE_ENABLED_TYPES: + return record + + result_id = f"{token.lower()}_{selected_key}" + saved_record = record + st.divider() + if saved_record: + st.caption(tx("Kaydedilmiş sonuç kimliği: {result_id}", "Saved result ID: {result_id}", result_id=result_id)) + saved_record, literature_action = render_literature_compare_panel( + record=saved_record, + result_id=result_id if saved_record else None, + lang=st.session_state.get("lang", "tr"), + key_prefix=f"{token.lower()}_literature_compare_{selected_key}", + ) + if literature_action and literature_action.get("status") == "success": + _log_event( + tx("Literatür Karşılaştırması", "Literature Compare"), + tx( + "{result_id} için literatür karşılaştırması güncellendi.", + "Literature comparison was refreshed for {result_id}.", + result_id=result_id, + ), + t(title_key), + dataset_key=selected_key, + result_id=result_id, + ) + return saved_record + + def render_spectral_page( analysis_type: str, *, @@ -782,4 +822,10 @@ def render_spectral_page( ) summary_rows = [{"key": key, "value": value} for key, value in summary.items()] st.dataframe(pd.DataFrame(summary_rows), width="stretch", hide_index=True) + _render_literature_compare_if_supported( + analysis_type=token, + selected_key=selected_key, + record=record, + title_key=title_key, + )