thth 2.5.0__tar.gz → 2.7.0__tar.gz
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.
- {thth-2.5.0 → thth-2.7.0}/PKG-INFO +17 -3
- {thth-2.5.0 → thth-2.7.0}/README.md +16 -2
- {thth-2.5.0 → thth-2.7.0}/mcp/server.py +14 -1
- thth-2.7.0/tests/test_27_acceptance_fixes.py +45 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_analytics_comparison.py +1 -1
- {thth-2.5.0 → thth-2.7.0}/tests/test_analytics_report.py +22 -3
- {thth-2.5.0 → thth-2.7.0}/tests/test_collect_sent.py +2 -1
- {thth-2.5.0 → thth-2.7.0}/tests/test_study_report.py +2 -1
- thth-2.7.0/tests/test_v31_analysis.py +219 -0
- thth-2.7.0/tests/test_v31_collection.py +181 -0
- thth-2.7.0/thth/VERSION +1 -0
- {thth-2.5.0 → thth-2.7.0}/thth/analytics_comparison.py +97 -11
- {thth-2.5.0 → thth-2.7.0}/thth/analytics_report.py +41 -4
- thth-2.7.0/thth/analytics_shapes.py +51 -0
- thth-2.7.0/thth/analytics_threads.py +95 -0
- {thth-2.5.0 → thth-2.7.0}/thth/cli.py +4 -0
- {thth-2.5.0 → thth-2.7.0}/thth/collect.py +21 -6
- thth-2.7.0/thth/collection_context.py +58 -0
- thth-2.7.0/thth/collection_status.py +59 -0
- thth-2.7.0/thth/handoff_cursor.py +168 -0
- {thth-2.5.0 → thth-2.7.0}/thth/operations_handoff.py +39 -5
- thth-2.7.0/thth/report_details.py +51 -0
- {thth-2.5.0 → thth-2.7.0}/thth/report_service.py +7 -3
- {thth-2.5.0 → thth-2.7.0}/thth/study_report.py +18 -1
- thth-2.5.0/thth/VERSION +0 -1
- {thth-2.5.0 → thth-2.7.0}/.gitignore +0 -0
- {thth-2.5.0 → thth-2.7.0}/LICENSE +0 -0
- {thth-2.5.0 → thth-2.7.0}/accounts.example/bluesky.json +0 -0
- {thth-2.5.0 → thth-2.7.0}/accounts.example/mastodon.json +0 -0
- {thth-2.5.0 → thth-2.7.0}/accounts.example/threads.json +0 -0
- {thth-2.5.0 → thth-2.7.0}/bin/thth +0 -0
- {thth-2.5.0 → thth-2.7.0}/bin/thth-remote +0 -0
- {thth-2.5.0 → thth-2.7.0}/bin/thth-run +0 -0
- {thth-2.5.0 → thth-2.7.0}/pyproject.toml +0 -0
- {thth-2.5.0 → thth-2.7.0}/skills/thth/SKILL.md +0 -0
- {thth-2.5.0 → thth-2.7.0}/systemd/thth@.service +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/__init__.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/conftest.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/fixtures/build_injection_eval.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/fixtures/callback/worker_responses.json +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/fixtures/injection_eval.json +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/fixtures/umami-bile.md +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/__init__.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_oauth_server.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_push_adapter.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_redirect_server.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_search_adapter.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_threads_reader.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/fake_threads_writes.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/hold_lock.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/hold_repo_lock.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/helpers/worker_probe.mjs +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_account_name_check.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_account_report.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_accounts_dir.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_adapter_boundary.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_after_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_api_diagnostic.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_api_error_body.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_app_env_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_approval.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_approve_cmd.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_ask.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_fifth_review.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_fourth_review.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_review.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_second_review.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_atlas_third_review.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_audit1_fixes.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_audit2_fixes.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_audit_b_counterexamples.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_audit_b_m1_semantics.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_audit_b_n1_depth.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_audit_b_r1_closure.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_audit_v201.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_audit_v23_details.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_auth.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_auth_identity_required.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_behind_remote.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_bluesky_adapter.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_bluesky_secret_scrub.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_board_needs_review.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_board_running.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_bundle.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_charcount.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_cli_entrypoint.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_cli_help.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_cli_vm_path.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_collect.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_collect_engagements.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_collect_runs.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_comparability.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_control_chars_refused.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_conversation_paging.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_count_for.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_docs_links.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_doctor.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_doctor_all_permissions.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_doctor_next_step.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_duplicate_front_matter_keys.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_editorial_audit.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_editorial_n3_n4.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_engagements.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_external_acceptance.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_fake_api.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_form_spec.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_form_spec_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_forms.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_fresh_install.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_front_matter_injection.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_healthcheck_notifications.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_hypotheses.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_hypothesis_boundaries.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_hypothesis_units.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_impact_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_inbox_granted_scopes.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_incident_notifications.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_independent_thread_review.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_independent_topic_review.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_independent_topic_review_r2.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_inflight.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_jst.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_lint.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_list_projections.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_llm_trial.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_llm_trial_engage.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_lock.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_m2_independent.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_maintain.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_mastodon_adapter.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_mastodon_visibility.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_max_per_run.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_mcp.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_mcp_robustness.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_measured.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_media_accounts.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_media_next_steps.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_media_roundtrip.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_mismatch_diagnostics.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_n5_reply_null_element.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_n7_doctor_error_body.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_no_limits.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_observation_provenance.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_operations_handoff.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_packaging.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_preview.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_production_mode.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_pull_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_queue.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_quiet_hours.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_r2_r4_r5.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_reason_adoption.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_redact.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_redirect_token_leak.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_refresh.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_refresh_destination.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_rejudge_new.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_rejudge_r2_r3.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_replies.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_replies_refresh.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_repo_lock.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_repo_sync.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_report_checkout_import.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_report_http.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_report_isolation.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_report_service.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_reverse_audit.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_review_case_id_cluster_m2.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_review_case_id_cluster_r2.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_review_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_review_evidence_account.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_review_form_spec_and_case_id.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_review_provenance.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_review_records.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_revoke.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_roundtrip.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_run_missing_env.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_run_record_failclosed.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_runs_collected.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_runs_read_rows.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_schedule_multiple.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_schedule_view.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_scopes_source_requested.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_search_engage_targets.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_search_media_routes.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_secret_value_redact.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_secrets_grep.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_security_small_fixes.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_select.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_selfupdate.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_send.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_send_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_send_control_chars.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_send_media.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_send_visibility.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_sent_integrity.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_server_json.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_site.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_skill_doc.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_systemd.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_thread_publish.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_thread_read.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_threadrun.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_threads_read_permissions.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_threadshape.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_thth_root.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_token_exists.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_token_scopes_record.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_token_set.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_topic.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_topic_advice.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_topic_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_topic_models.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_topic_review_fixes.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_topic_shelf_observers.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_topic_store.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_topics_record_concurrent.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_topics_tool.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_trial_frictions.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_v21b_approved_writes.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_value_domains_shown.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_version.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_where_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_who_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_writeback_rebase.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_writeback_validate.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/tests/test_x_metrics.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/__init__.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/__main__.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/account_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/account_report.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/accounts.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/adapters/__init__.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/adapters/base.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/adapters/bluesky.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/adapters/mastodon.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/adapters/threads.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/after_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/api_diagnostic.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/appenv.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/approval.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/ask.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/ask_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/bundle.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/core.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/doctor.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/engagements.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/forms.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/healthcheck.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/httpsafe.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/incident.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/inflight.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/jst.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/lint.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/lock.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/maintain.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/measured.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/oauth.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/postid.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/queuefile.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/redact.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/replies.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/report.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/report_http.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/report_isolation.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/retract_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/runs.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/scopes.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/secrets_fs.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/select.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/selfupdate.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/sent.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/systemd_gen.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/thread_read.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/threadrun.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/threads_read_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/threadshape.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/threadthrow.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/topic_advice.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/topic_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/topic_models.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/topic_store.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/topics.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/where_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/who_cli.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/writeback.py +0 -0
- {thth-2.5.0 → thth-2.7.0}/thth/x_metrics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thth
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: 承認済みの投稿だけを投げる門(Threads・Bluesky・Mastodon)。人が承認し、記録は利用者の git に残る。
|
|
5
5
|
Project-URL: Homepage, https://thth.me
|
|
6
6
|
Project-URL: Source, https://github.com/aokings/thth
|
|
@@ -33,7 +33,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
33
33
|
|
|
34
34
|
## 版
|
|
35
35
|
|
|
36
|
-
このソースの版は **2.
|
|
36
|
+
このソースの版は **2.7.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
|
|
37
37
|
|
|
38
38
|
版の正本は `thth/VERSION`(`server.json` はテストで一致を強制)。開発中の `main` には未配布の変更も含まれます。インストール済みの版は `thth --version`、VM の版と revision は `thth board` で確認してください。
|
|
39
39
|
|
|
@@ -45,7 +45,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
45
45
|
|
|
46
46
|
## 実装と運用の状態(2026-09-18)
|
|
47
47
|
|
|
48
|
-
2.
|
|
48
|
+
2.7.0 の実装 revision `f331e8c` の全件テストは **2743 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。開発中の変更の検証は対象 revision の CI を参照してください。
|
|
49
49
|
|
|
50
50
|
**媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
|
|
51
51
|
|
|
@@ -59,6 +59,20 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
59
59
|
|
|
60
60
|
**トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
|
|
61
61
|
|
|
62
|
+
### v2.7.0 の変更
|
|
63
|
+
|
|
64
|
+
- 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
|
|
65
|
+
- 新しい観測に、採取時刻以前48時間以内で最も新しい日次フォロワー数を文脈として添えます。`followers_count_at` と `staleness_hours` を含み、追加APIや過去行の後補完はありません。該当しない場合は `context_reason` に理由を残します。
|
|
66
|
+
- Threads・Bluesky・Mastodonの刻みを揃え、媒体にないviewsはnullのまま維持。[合成検証と限界](docs/検証_v31_C実装_2026-09-18.md)。実SNS APIでの30日後採集は未確認です。
|
|
67
|
+
- C3・C4は実装保留。[独立規約照合報告](docs/照合_MetaPlatformTerms_枝と語の観測_2026-09-18_Codex.md)の未確認事項とApp Reviewの結果を踏まえて別途判断します。
|
|
68
|
+
|
|
69
|
+
### v2.6.0 の変更
|
|
70
|
+
|
|
71
|
+
- 既存台帳から刻み別の値、中央値の IQR・min・max、枝別の帰結、採集成否と鮮度、層別比較、採集時刻を上限とした枝の形を追加。既存キーは維持し `schema_version=1` のままです。
|
|
72
|
+
- `cannot_say_details` にコードと既存の説明文を併記。施策レポートには未成熟投稿が24時間に達する予定を添え、採集保証とは分けます。
|
|
73
|
+
- `handoff-report --since-last-read` で保存済みsnapshotとの差分。cursor保存は CLI の `--mark-read --by 名前` を明示したときだけ。MCP・HTTPは読むだけです。
|
|
74
|
+
- API呼出を増やさない分析の改良です。VM5accountの読取検証と限界は [検証記録](docs/検証_v31_A実装_2026-09-18.md) に記載。Bluesky/Mastodon の非空実台帳と実採用施策の検証は未確認です。
|
|
75
|
+
|
|
62
76
|
### v2.5.0 の変更
|
|
63
77
|
|
|
64
78
|
- 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
|
|
@@ -9,7 +9,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
9
9
|
|
|
10
10
|
## 版
|
|
11
11
|
|
|
12
|
-
このソースの版は **2.
|
|
12
|
+
このソースの版は **2.7.0**。現在の設計は [自分の泉](docs/設計_自分の泉_2026-09-16.md)、導入は [自分の Meta アプリで動かす](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
|
|
13
13
|
|
|
14
14
|
版の正本は `thth/VERSION`(`server.json` はテストで一致を強制)。開発中の `main` には未配布の変更も含まれます。インストール済みの版は `thth --version`、VM の版と revision は `thth board` で確認してください。
|
|
15
15
|
|
|
@@ -21,7 +21,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
21
21
|
|
|
22
22
|
## 実装と運用の状態(2026-09-18)
|
|
23
23
|
|
|
24
|
-
2.
|
|
24
|
+
2.7.0 の実装 revision `f331e8c` の全件テストは **2743 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。開発中の変更の検証は対象 revision の CI を参照してください。
|
|
25
25
|
|
|
26
26
|
**媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
|
|
27
27
|
|
|
@@ -35,6 +35,20 @@ timer(systemd・`thth systemd` で生成)は10分ごとに実行し、投稿
|
|
|
35
35
|
|
|
36
36
|
**トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
|
|
37
37
|
|
|
38
|
+
### v2.7.0 の変更
|
|
39
|
+
|
|
40
|
+
- 指標採集に720時間(30日)を追加し、対象窓を刻みから38日と計算。正常時は指標6回、返信は従来の5回です。返信の採集窓は独立して維持します。
|
|
41
|
+
- 新しい観測に、採取時刻以前48時間以内で最も新しい日次フォロワー数を文脈として添えます。`followers_count_at` と `staleness_hours` を含み、追加APIや過去行の後補完はありません。該当しない場合は `context_reason` に理由を残します。
|
|
42
|
+
- Threads・Bluesky・Mastodonの刻みを揃え、媒体にないviewsはnullのまま維持。[合成検証と限界](docs/検証_v31_C実装_2026-09-18.md)。実SNS APIでの30日後採集は未確認です。
|
|
43
|
+
- C3・C4は実装保留。[独立規約照合報告](docs/照合_MetaPlatformTerms_枝と語の観測_2026-09-18_Codex.md)の未確認事項とApp Reviewの結果を踏まえて別途判断します。
|
|
44
|
+
|
|
45
|
+
### v2.6.0 の変更
|
|
46
|
+
|
|
47
|
+
- 既存台帳から刻み別の値、中央値の IQR・min・max、枝別の帰結、採集成否と鮮度、層別比較、採集時刻を上限とした枝の形を追加。既存キーは維持し `schema_version=1` のままです。
|
|
48
|
+
- `cannot_say_details` にコードと既存の説明文を併記。施策レポートには未成熟投稿が24時間に達する予定を添え、採集保証とは分けます。
|
|
49
|
+
- `handoff-report --since-last-read` で保存済みsnapshotとの差分。cursor保存は CLI の `--mark-read --by 名前` を明示したときだけ。MCP・HTTPは読むだけです。
|
|
50
|
+
- API呼出を増やさない分析の改良です。VM5accountの読取検証と限界は [検証記録](docs/検証_v31_A実装_2026-09-18.md) に記載。Bluesky/Mastodon の非空実台帳と実採用施策の検証は未確認です。
|
|
51
|
+
|
|
38
52
|
### v2.5.0 の変更
|
|
39
53
|
|
|
40
54
|
- 読み取り専用のレポート 3 本: `analytics-report`(活動のスナップショット・`--compare-previous` で隣接期間の比較)・`handoff-report`(ローカル運用記録の引継ぎ)・`study-report`(施策の宣言と本人の観測の結合)。MCP に `analytics_report`・`operations_handoff`・`study_report`。数値は期間・母数・欠測・根拠を連れて歩き、因果や推奨は出しません。
|
|
@@ -143,7 +143,8 @@ TOOLS = [
|
|
|
143
143
|
"name": "operations_handoff",
|
|
144
144
|
"description": "ローカル運用記録の状態・承認待ち・inflight・通知未処理を根拠と鮮度の制約付きで返す。同期・承認・再送はしない",
|
|
145
145
|
"inputSchema": {"type": "object", "properties": {
|
|
146
|
-
"account": {"type": "string"}, "project": {"type": "string"}
|
|
146
|
+
"account": {"type": "string"}, "project": {"type": "string"},
|
|
147
|
+
"since_last_read": {"type": "boolean"}}},
|
|
147
148
|
},
|
|
148
149
|
{
|
|
149
150
|
"name": "study_report",
|
|
@@ -159,6 +160,7 @@ TOOLS = [
|
|
|
159
160
|
"inputSchema": {
|
|
160
161
|
"type": "object",
|
|
161
162
|
"properties": {
|
|
163
|
+
"by": {"type": "string", "enum": ["kind", "hour_band", "topic"]},
|
|
162
164
|
"compare_previous": {"type": "boolean", "description": "直前の同じ日数との比較(既定false)"},
|
|
163
165
|
"account": {"type": "string"},
|
|
164
166
|
"project": {"type": "string"},
|
|
@@ -397,6 +399,11 @@ def validate_arguments(name: str, arguments) -> dict:
|
|
|
397
399
|
raise ToolInputError(
|
|
398
400
|
f"{name}: arguments は object で渡してください"
|
|
399
401
|
f"(受け取った: {type(arguments).__name__})")
|
|
402
|
+
if name == "analytics_report" and "by" in arguments:
|
|
403
|
+
if arguments["by"] not in ("kind", "hour_band", "topic") or arguments.get("compare_previous") is not True:
|
|
404
|
+
raise ToolInputError("analytics_report: by は compare_previous=true と kind/hour_band/topic が必要です")
|
|
405
|
+
if name == "operations_handoff" and "since_last_read" in arguments and type(arguments["since_last_read"]) is not bool:
|
|
406
|
+
raise ToolInputError("operations_handoff: since_last_read は boolean です")
|
|
400
407
|
props = schema.get("properties") or {}
|
|
401
408
|
for key in schema.get("required") or []:
|
|
402
409
|
if arguments.get(key) is None:
|
|
@@ -533,6 +540,10 @@ def call_tool(name: str, arguments: dict | None) -> dict:
|
|
|
533
540
|
text = proc.stdout
|
|
534
541
|
elif name == "operations_handoff":
|
|
535
542
|
args = ["handoff-report"]
|
|
543
|
+
if "mark_read" in arguments or "by" in arguments:
|
|
544
|
+
raise ValueError("operations_handoff is read-only")
|
|
545
|
+
if arguments.get("since_last_read"):
|
|
546
|
+
args.append("--since-last-read")
|
|
536
547
|
if arguments.get("project"):
|
|
537
548
|
args += ["--project", arguments["project"]]
|
|
538
549
|
else:
|
|
@@ -558,6 +569,8 @@ def call_tool(name: str, arguments: dict | None) -> dict:
|
|
|
558
569
|
args += ["--" + key.replace("_", "-"), str(arguments[key])]
|
|
559
570
|
if arguments.get("compare_previous"):
|
|
560
571
|
args.append("--compare-previous")
|
|
572
|
+
if arguments.get("by"):
|
|
573
|
+
args.extend(["--by", arguments["by"]])
|
|
561
574
|
args.append("--json")
|
|
562
575
|
proc = run_cli(args)
|
|
563
576
|
text = proc.stdout
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from thth import collection_context, jst
|
|
5
|
+
|
|
6
|
+
NOW=jst.parse('2026-10-01T01:00:00+09:00')
|
|
7
|
+
|
|
8
|
+
def daily(value,age,**extra):
|
|
9
|
+
return {'account':'a','date':'ignored','collected_at':jst.iso(NOW-dt.timedelta(hours=age)),
|
|
10
|
+
'metrics':{'followers_count':value},**extra}
|
|
11
|
+
|
|
12
|
+
def test_recent_daily_across_month_and_date_ignored(tmp_path):
|
|
13
|
+
(tmp_path/'a-2026-09.ndjson').write_text(json.dumps(daily(120,2,date='2026-09-29')))
|
|
14
|
+
value=collection_context.followers('a',tmp_path,NOW)
|
|
15
|
+
assert value=={'followers_count':120,'followers_count_at':'2026-09-30T23:00:00+09:00',
|
|
16
|
+
'staleness_hours':2.0,'source':'insights_account'}
|
|
17
|
+
(tmp_path/'a-2026-10.ndjson').write_text(json.dumps(daily(121,1.234)))
|
|
18
|
+
value=collection_context.followers('a',tmp_path,NOW)
|
|
19
|
+
assert value['followers_count']==121 and value['staleness_hours']==1.23
|
|
20
|
+
|
|
21
|
+
def test_recent_daily_inclusive_48h_and_no_old_fallback(tmp_path):
|
|
22
|
+
p=tmp_path/'a-2026-09.ndjson'
|
|
23
|
+
p.write_text(json.dumps(daily(0,48)))
|
|
24
|
+
assert collection_context.followers('a',tmp_path,NOW)['followers_count']==0
|
|
25
|
+
p.write_text(json.dumps(daily(2,49)))
|
|
26
|
+
assert collection_context.followers('a',tmp_path,NOW) is None
|
|
27
|
+
p.write_text('\n'.join(map(json.dumps,[daily(2,3),daily(True,1)])))
|
|
28
|
+
assert collection_context.followers('a',tmp_path,NOW) is None
|
|
29
|
+
|
|
30
|
+
def test_recent_daily_collect_reason_and_no_retrofit(tmp_path,isolated_account_factory,monkeypatch):
|
|
31
|
+
from tests.test_v31_collection import setup_route,Adapter,POSTED,daily_path,daily_row
|
|
32
|
+
from thth import collect,accounts
|
|
33
|
+
a=setup_route(tmp_path,isolated_account_factory,monkeypatch,'sent')
|
|
34
|
+
now=POSTED+dt.timedelta(hours=1)
|
|
35
|
+
p=daily_path(a)
|
|
36
|
+
p.write_text(json.dumps(daily_row(a,9,collected_at=jst.iso(now-dt.timedelta(hours=49)))))
|
|
37
|
+
collect.collect_once(a['name'],adapter=Adapter(),now=now,log=lambda _:None)
|
|
38
|
+
out=Path(accounts.data_dirs(a,a['name'])['insights_posts'])/'P.ndjson'
|
|
39
|
+
before=out.read_bytes()
|
|
40
|
+
assert json.loads(before)['context'] is None
|
|
41
|
+
assert json.loads(before)['context_reason']=='no_recent_daily_record'
|
|
42
|
+
p.write_text(json.dumps(daily_row(a,0,collected_at=jst.iso(now))))
|
|
43
|
+
collect.collect_once(a['name'],adapter=Adapter(),now=POSTED+dt.timedelta(hours=6),log=lambda _:None)
|
|
44
|
+
assert out.read_bytes().startswith(before)
|
|
45
|
+
assert json.loads(out.read_text().splitlines()[-1])['context']['followers_count']==0
|
|
@@ -85,7 +85,7 @@ def test_invalid_metric_does_not_become_zero(isolated_account_factory, value):
|
|
|
85
85
|
seed(a, "p", NOW - datetime.timedelta(days=3), value=value)
|
|
86
86
|
p = report(a, min_n=1)["by_account"][a["name"]]["posts"]["current"]
|
|
87
87
|
assert p["n_eligible"] == 1 # time-eligible does not imply every metric exists
|
|
88
|
-
assert p["metrics"]["views"] == {"n_total": 1, "n_eligible": 0, "n_missing": 1, "median": None}
|
|
88
|
+
assert p["metrics"]["views"] == {"n_total": 1, "n_eligible": 0, "n_missing": 1, "median": None, "iqr": None, "min": None, "max": None, "spread_reason": "below_min_n"}
|
|
89
89
|
json.dumps(p, allow_nan=False)
|
|
90
90
|
|
|
91
91
|
|
|
@@ -32,9 +32,19 @@ def test_boundaries_evidence_missing_and_no_writes(isolated_account_factory):
|
|
|
32
32
|
node = result["by_account"][account["name"]]
|
|
33
33
|
original = after_cli.answer(account["name"], now=NOW, window_days=7)
|
|
34
34
|
for key in ("posts", "engagements", "comparable", "cannot_say"):
|
|
35
|
-
|
|
35
|
+
def legacy_projection(actual, original):
|
|
36
|
+
if isinstance(original, dict):
|
|
37
|
+
return {k: legacy_projection(actual[k], v) for k, v in original.items()}
|
|
38
|
+
if isinstance(original, list):
|
|
39
|
+
assert len(actual) == len(original)
|
|
40
|
+
return [legacy_projection(a, b) for a, b in zip(actual, original)]
|
|
41
|
+
return actual
|
|
42
|
+
actual = node[key]
|
|
43
|
+
if key == "cannot_say":
|
|
44
|
+
actual = [x for x in actual if not x.startswith("collection_records_")]
|
|
45
|
+
assert legacy_projection(actual, original[key]) == original[key]
|
|
36
46
|
assert {p["post_id"] for p in node["posts"]["by_post"]} == {"start", "end"}
|
|
37
|
-
assert node["posts"]["views_24h"] == {"median": None, "n": 1}
|
|
47
|
+
assert node["posts"]["views_24h"] == {"median": None, "n": 1, "iqr": None, "min": None, "max": None, "spread_reason": "below_min_n"}
|
|
38
48
|
end = next(p for p in node["posts"]["by_post"] if p["post_id"] == "end")
|
|
39
49
|
assert end["views_24h"] is None and end["covered"] is False
|
|
40
50
|
assert node["engagements"]["by_branch"][0]["post_id"] == "reply"
|
|
@@ -46,7 +56,16 @@ def test_boundaries_evidence_missing_and_no_writes(isolated_account_factory):
|
|
|
46
56
|
assert result["generated_at"] == "2026-09-17T08:00:00+09:00"
|
|
47
57
|
text = json.dumps(result)
|
|
48
58
|
assert "PRIVATE BODY" not in text
|
|
49
|
-
assert '"username"' not in text
|
|
59
|
+
assert '"username"' not in text
|
|
60
|
+
def assert_text_only_in_details(value, details=False):
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
assert "text" not in value or details
|
|
63
|
+
for key, child in value.items():
|
|
64
|
+
assert_text_only_in_details(child, key == "cannot_say_details")
|
|
65
|
+
elif isinstance(value, list):
|
|
66
|
+
for child in value:
|
|
67
|
+
assert_text_only_in_details(child, details)
|
|
68
|
+
assert_text_only_in_details(result)
|
|
50
69
|
markdown = analytics_report.render_markdown(result)
|
|
51
70
|
recovered = "\n".join(line[4:] for line in markdown.splitlines() if line.startswith(" "))
|
|
52
71
|
assert json.loads(recovered) == result
|
|
@@ -220,7 +220,8 @@ def test_d_従来の台帳は置き場も行の中身も変わらない(tmp_path
|
|
|
220
220
|
assert set(rows[0]) == {
|
|
221
221
|
"post_id", "file", "source", "account", "medium", "topic", "reply_to",
|
|
222
222
|
"text_length", "has_link", "collected_at", "posted_at", "age_hours",
|
|
223
|
-
"marks", "metrics"}, sorted(rows[0])
|
|
223
|
+
"marks", "metrics", "context", "context_reason"}, sorted(rows[0])
|
|
224
|
+
assert rows[0]["context"] is None
|
|
224
225
|
assert rows[0]["source"] == "queue"
|
|
225
226
|
assert rows[0]["file"] == "a.md"
|
|
226
227
|
assert rows[0]["age_hours"] == 2.0
|
|
@@ -149,7 +149,8 @@ def test_broken_source_immature_and_sparse_remain_unknown(isolated_account_facto
|
|
|
149
149
|
assert r["observations"]["changed"]["n_immature"] == 1
|
|
150
150
|
assert r["observations"]["baseline"]["metrics"]["views"]["n_eligible"] == 1
|
|
151
151
|
assert all(v["absolute_median_change"] is None for v in r["comparison"].values())
|
|
152
|
-
assert len(r["cannot_say"]) ==
|
|
152
|
+
assert len(r["cannot_say"]) == 3
|
|
153
|
+
assert r["cannot_say"][-1] == "forecast_assumes_collection_runs"
|
|
153
154
|
|
|
154
155
|
|
|
155
156
|
def test_invalid_cli_is_error_without_input_disclosure(isolated_account_factory, capsys):
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
from thth import analytics_comparison as c, jst
|
|
3
|
+
|
|
4
|
+
NOW = jst.parse('2026-09-18T12:00:00+09:00')
|
|
5
|
+
POSTED = NOW - dt.timedelta(days=3)
|
|
6
|
+
|
|
7
|
+
def row(mark, age, value=0):
|
|
8
|
+
return {'posted_at': jst.iso(POSTED), 'collected_at': jst.iso(POSTED + dt.timedelta(hours=age)),
|
|
9
|
+
'marks': [mark], 'metrics': {'likes': value}}
|
|
10
|
+
|
|
11
|
+
def test_a1_strict_marks():
|
|
12
|
+
post = {'rows': [row(1, 1.25, 8), row(1, 1, 0), row(6, 7.5), row(24, 25, 9)]}
|
|
13
|
+
result = c._marks_population([('p', POSTED, post)], POSTED, NOW, NOW, 1)
|
|
14
|
+
assert result['by_mark']['1']['metrics']['likes']['median'] == 0
|
|
15
|
+
assert result['by_mark']['1']['metrics']['views']['median'] is None
|
|
16
|
+
assert result['marks_by_post'][0]['marks']['6'] is None
|
|
17
|
+
assert result['by_mark']['24']['metrics']['likes']['median'] == 9
|
|
18
|
+
assert result['by_mark']['168']['n_eligible'] == 0
|
|
19
|
+
assert c._marks_population([('p', POSTED, post)], POSTED, NOW, NOW, 2)['by_mark']['1']['metrics']['likes']['median'] is None
|
|
20
|
+
|
|
21
|
+
def test_a1_reject_collapsed_future_conflicting():
|
|
22
|
+
rows = [row(1, 1), row(1, 2), row(1, 1)]
|
|
23
|
+
rows[0]['marks_collapsed'] = True
|
|
24
|
+
rows[2]['posted_at'] = jst.iso(POSTED-dt.timedelta(hours=1))
|
|
25
|
+
assert c._observation({'rows': rows}, POSTED, NOW, 1)[0] is None
|
|
26
|
+
assert c._observation({'rows': [row(168, 168)]}, POSTED, NOW, 168)[0] is None
|
|
27
|
+
|
|
28
|
+
def test_a5_spread_gate_and_odd_center():
|
|
29
|
+
assert c._spread([1, 2, 3, 4], 5) == {'iqr': None, 'min': None, 'max': None, 'spread_reason': 'below_min_n'}
|
|
30
|
+
assert c._spread([0], 1) == {'iqr': None, 'min': 0, 'max': 0, 'spread_reason': 'too_few_for_quartiles'}
|
|
31
|
+
assert c._spread([1, 2, 3], 1)['iqr'] is None
|
|
32
|
+
assert c._spread([1, 2, 50, 80, 100], 1)['iqr'] == 88.5
|
|
33
|
+
assert c._spread([1, 2, 3, 4], 1)['iqr'] == 2
|
|
34
|
+
|
|
35
|
+
def test_a8_details_preserve_order_text():
|
|
36
|
+
from thth.report_details import attach
|
|
37
|
+
messages = ['timer_health_unknown', '自分の投稿の 24h views が欠測: 2 本', '未知の説明']
|
|
38
|
+
result = attach({'cannot_say': list(messages)})
|
|
39
|
+
assert result['cannot_say'] == messages
|
|
40
|
+
assert result['cannot_say_details'] == [
|
|
41
|
+
{'code': 'timer_health_unknown', 'text': messages[0]},
|
|
42
|
+
{'code': 'post_views_missing', 'text': messages[1]},
|
|
43
|
+
{'code': 'report_limitation', 'text': messages[2]}]
|
|
44
|
+
|
|
45
|
+
def test_a1_malformed_mark_types():
|
|
46
|
+
for mark in (1, 6, 24, 72, 168):
|
|
47
|
+
for bad in (True, False, str(mark), float(mark)):
|
|
48
|
+
value = row(mark, mark)
|
|
49
|
+
value['marks'] = [bad]
|
|
50
|
+
assert c._observation({'rows': [value]}, POSTED, NOW + dt.timedelta(days=10), mark)[0] is None
|
|
51
|
+
|
|
52
|
+
def test_a2_reaction_identity_and_missing(monkeypatch):
|
|
53
|
+
from thth import analytics_threads as t
|
|
54
|
+
ledger = {'broken': [], 'unreadable_accounts': [], 'fetches': [], 'replies': []}
|
|
55
|
+
monkeypatch.setattr(t.replies, 'load', lambda *a, **k: ledger)
|
|
56
|
+
e = {'post_id': 'mine', 'author_key': '0123456789abcdef'}
|
|
57
|
+
assert t.reaction('a', 'threads', e, POSTED, NOW) == (None, None)
|
|
58
|
+
ledger['fetches'] = [{'post_id':'mine', 'collected_at': jst.iso(NOW)}]
|
|
59
|
+
assert t.reaction('a', 'threads', e, POSTED, NOW) == (False, None)
|
|
60
|
+
ledger['replies'] = [{'post_id':'mine', 'collected_at': jst.iso(NOW), 'timestamp': jst.iso(POSTED+dt.timedelta(hours=2)), 'own': False, 'replied_to': {'id':'mine'}, 'author_key': e['author_key'], 'text':'SECRET'}]
|
|
61
|
+
assert t.reaction('a', 'threads', e, POSTED, NOW) == (True, 2)
|
|
62
|
+
ledger['replies'][0]['replied_to'] = {'id': 'someone_else'}
|
|
63
|
+
assert t.reaction('a', 'threads', e, POSTED, NOW) == (False, None)
|
|
64
|
+
|
|
65
|
+
def test_a3_skip_not_success(tmp_path, monkeypatch):
|
|
66
|
+
import json
|
|
67
|
+
from thth import collection_status as s
|
|
68
|
+
monkeypatch.setattr(s.accounts, 'state_dir_for', lambda name: tmp_path)
|
|
69
|
+
assert s.summarize('a', NOW)[0] is None
|
|
70
|
+
p = tmp_path/'runs-2026-09.ndjson'
|
|
71
|
+
rows = [{'account':'a', 'mode':'collect', 'action':'collect', 'run_id':'collect-'+jst.iso(POSTED), 'status':'ok','collected':0},
|
|
72
|
+
{'account':'a', 'mode':'collect', 'action':'collect', 'run_id':'collect-'+jst.iso(NOW), 'status':'ok','collected':None}]
|
|
73
|
+
p.write_text('\n'.join(map(json.dumps,rows)))
|
|
74
|
+
value, reasons = s.summarize('a', NOW)
|
|
75
|
+
assert value['last_success_at'] == jst.iso(POSTED)
|
|
76
|
+
assert value['last_attempt_ok'] is None
|
|
77
|
+
rows[-1]['collected'] = True
|
|
78
|
+
p.write_text('\n'.join(map(json.dumps,rows)))
|
|
79
|
+
assert s.summarize('a', NOW)[0]['last_attempt_ok'] is None
|
|
80
|
+
|
|
81
|
+
def test_a6_unknown_stratum_reconciles(isolated_account_factory):
|
|
82
|
+
from tests.test_analytics_comparison import seed, report
|
|
83
|
+
from thth import analytics_report
|
|
84
|
+
a = isolated_account_factory()
|
|
85
|
+
seed(a, 'p', NOW-dt.timedelta(days=3), value=0)
|
|
86
|
+
result = analytics_report.answer(a['name'], now=NOW, compare_previous=True, by='kind', min_n=1)
|
|
87
|
+
group = result['by_account'][a['name']]['posts']
|
|
88
|
+
assert group['stratified']['strata']['unknown']['current']['n_total'] == 1
|
|
89
|
+
assert group['stratified']['reconciliation']['current'] == {'sum_n_total':1,'n_total':1}
|
|
90
|
+
assert group['stratified']['strata']['unknown']['current']['metrics']['views']['median'] == 0
|
|
91
|
+
|
|
92
|
+
def test_a7_collected_time_bounds_history(monkeypatch):
|
|
93
|
+
from thth import analytics_shapes as s
|
|
94
|
+
early = POSTED+dt.timedelta(hours=23)
|
|
95
|
+
late = POSTED+dt.timedelta(hours=25)
|
|
96
|
+
ledger = {'broken': [], 'fetches':[{'post_id':'p', 'collected_at':jst.iso(early)}, {'post_id':'p', 'collected_at':jst.iso(late)}], 'replies':[
|
|
97
|
+
{'post_id':'p','id':'r','own':False,'username':'private','text':'SECRET','replied_to':{'id':'p'},
|
|
98
|
+
'timestamp':jst.iso(POSTED+dt.timedelta(hours=1)), 'collected_at':jst.iso(late)}]}
|
|
99
|
+
monkeypatch.setattr(s.replies,'load',lambda *a,**k:ledger)
|
|
100
|
+
value=s.shape_at('a','p',POSTED,NOW)
|
|
101
|
+
assert value['24']['replies_total']==0
|
|
102
|
+
assert value['72']['replies_total']==1
|
|
103
|
+
assert value['168'] is None
|
|
104
|
+
assert 'SECRET' not in str(value) and 'private' not in str(value)
|
|
105
|
+
ledger['fetches']=[]
|
|
106
|
+
assert s.shape_at('a','p',POSTED,NOW)['24'] is None
|
|
107
|
+
|
|
108
|
+
def test_a9_forecast_only_immature():
|
|
109
|
+
from thth.study_report import _eligibility_forecast
|
|
110
|
+
late=NOW-dt.timedelta(hours=1)
|
|
111
|
+
value=_eligibility_forecast({'evidence':[
|
|
112
|
+
{'status':'immature','posted_at':jst.iso(NOW-dt.timedelta(hours=2))},
|
|
113
|
+
{'status':'immature','posted_at':jst.iso(late)},
|
|
114
|
+
{'status':'incomplete','posted_at':jst.iso(POSTED)}]})
|
|
115
|
+
assert value=={'posts_immature':2, 'all_eligible_at':jst.iso(late+dt.timedelta(hours=24)), 'basis':'posted_at_plus_24h'}
|
|
116
|
+
assert _eligibility_forecast({'evidence':[]}) is None
|
|
117
|
+
|
|
118
|
+
def test_a4_explicit_cli_write_and_readonly_delta(isolated_account_factory, monkeypatch, capsys):
|
|
119
|
+
import json
|
|
120
|
+
from pathlib import Path
|
|
121
|
+
from types import SimpleNamespace
|
|
122
|
+
from thth import operations_handoff as h, handoff_cursor as cursor, accounts
|
|
123
|
+
from tests.test_operations_handoff import seed
|
|
124
|
+
a=isolated_account_factory()
|
|
125
|
+
name=a['name']
|
|
126
|
+
monkeypatch.setattr(h.jst,'now_jst',lambda:NOW)
|
|
127
|
+
seed(a,'draft.md','draft')
|
|
128
|
+
path=Path(accounts.state_dir_for(name))/'handoff_cursor.json'
|
|
129
|
+
args=SimpleNamespace(account=name,project=None,json=True,mark_read=False,by=None,since_last_read=True)
|
|
130
|
+
assert h.cmd_handoff_report(args)==0
|
|
131
|
+
assert not path.exists()
|
|
132
|
+
args.mark_read=True
|
|
133
|
+
assert h.cmd_handoff_report(args)==2
|
|
134
|
+
assert not path.exists()
|
|
135
|
+
args.by='reader'
|
|
136
|
+
assert h.cmd_handoff_report(args)==0
|
|
137
|
+
saved=path.read_bytes()
|
|
138
|
+
node=h.answer(name,now=NOW,since_last_read=True)['by_account'][name]
|
|
139
|
+
assert node['changes_since']['changes']==[]
|
|
140
|
+
assert 'no_previous_session_cursor' not in node['cannot_say']
|
|
141
|
+
seed(a,'draft2.md','draft')
|
|
142
|
+
node=h.answer(name,now=NOW,since_last_read=True)['by_account'][name]
|
|
143
|
+
assert next(r for r in node['changes_since']['changes'] if r['field']=='queue_counts.draft')['delta']==1
|
|
144
|
+
assert path.read_bytes()==saved
|
|
145
|
+
path.unlink()
|
|
146
|
+
path.symlink_to(path.parent/'outside')
|
|
147
|
+
assert cursor.read(name,NOW)[1]=='cursor_unreadable'
|
|
148
|
+
args.mark_read=True
|
|
149
|
+
assert h.cmd_handoff_report(args)==2
|
|
150
|
+
assert not (path.parent/'outside').exists()
|
|
151
|
+
|
|
152
|
+
def test_a4_unknown_counts_not_zero():
|
|
153
|
+
from thth.handoff_cursor import changes
|
|
154
|
+
import copy
|
|
155
|
+
base={'queue_counts':dict.fromkeys(['draft','approved_waiting','overdue','malformed','unattributed_malformed']),
|
|
156
|
+
'inflight':{'present':None,'since':None},'notification_last_event_id':None,
|
|
157
|
+
'notification_recorded_state':None,'run_last_attempt_at':None,'run_recorded_state':None,
|
|
158
|
+
'last_post_observed_at':None,'sent_count':None}
|
|
159
|
+
current=copy.deepcopy(base);current['queue_counts']['draft']=0
|
|
160
|
+
assert changes(base,current)==[{'field':'queue_counts.draft','previous':None,'current':0,'delta':None}]
|
|
161
|
+
|
|
162
|
+
def test_a4_ancestor_swap_does_not_redirect(tmp_path, monkeypatch):
|
|
163
|
+
import os
|
|
164
|
+
from thth import handoff_cursor as cursor
|
|
165
|
+
from pathlib import Path
|
|
166
|
+
state=tmp_path/'state';state.mkdir()
|
|
167
|
+
(state/'a').mkdir()
|
|
168
|
+
outside=tmp_path/'outside';outside.mkdir();(outside/'a').mkdir()
|
|
169
|
+
original=tmp_path/'original'
|
|
170
|
+
monkeypatch.setattr(cursor.accounts,'state_dir_for',lambda name:state/name)
|
|
171
|
+
real_open=os.open
|
|
172
|
+
swapped=False
|
|
173
|
+
def race(path, flags, *args, **kwargs):
|
|
174
|
+
nonlocal swapped
|
|
175
|
+
# The state descriptor must already be held when its child is opened.
|
|
176
|
+
if path=='a' and not swapped:
|
|
177
|
+
state.rename(original)
|
|
178
|
+
state.symlink_to(outside,target_is_directory=True)
|
|
179
|
+
swapped=True
|
|
180
|
+
return real_open(path,flags,*args,**kwargs)
|
|
181
|
+
monkeypatch.setattr(cursor.os,'open',race)
|
|
182
|
+
fd=cursor._directory('a',create=True)
|
|
183
|
+
try:
|
|
184
|
+
child=real_open('proof',os.O_CREAT|os.O_WRONLY,0o600,dir_fd=fd)
|
|
185
|
+
os.close(child)
|
|
186
|
+
finally:os.close(fd)
|
|
187
|
+
assert swapped
|
|
188
|
+
assert (original/'a'/'proof').exists()
|
|
189
|
+
assert not (outside/'a'/'proof').exists()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_a4_read_mode_limitations(isolated_account_factory):
|
|
193
|
+
from thth import operations_handoff as h
|
|
194
|
+
name=isolated_account_factory()['name']
|
|
195
|
+
assert h.answer(name,now=NOW)['limitations'][0]=='ローカル保存記録の現在の読み取り。前回セッション以降の差分ではない'
|
|
196
|
+
assert h.answer(name,now=NOW,since_last_read=True)['limitations'][0]=='保存済みsnapshotと現在の値の差分。間に起きた全イベントを復元するものではない'
|
|
197
|
+
|
|
198
|
+
def test_a4_ancestor_swap_before_open_is_rejected(tmp_path, monkeypatch):
|
|
199
|
+
import os
|
|
200
|
+
import pytest
|
|
201
|
+
from thth import handoff_cursor as cursor
|
|
202
|
+
state=tmp_path/'state';state.mkdir()
|
|
203
|
+
outside=tmp_path/'outside';outside.mkdir();(outside/'a').mkdir()
|
|
204
|
+
monkeypatch.setattr(cursor.accounts,'state_dir_for',lambda name:state/name)
|
|
205
|
+
real_open=os.open
|
|
206
|
+
swapped=False
|
|
207
|
+
def race(path, flags, *args, **kwargs):
|
|
208
|
+
nonlocal swapped
|
|
209
|
+
if path=='state' and not swapped:
|
|
210
|
+
state.rename(tmp_path/'original')
|
|
211
|
+
state.symlink_to(outside,target_is_directory=True)
|
|
212
|
+
swapped=True
|
|
213
|
+
return real_open(path,flags,*args,**kwargs)
|
|
214
|
+
monkeypatch.setattr(cursor.os,'open',race)
|
|
215
|
+
with pytest.raises(OSError):
|
|
216
|
+
fd=cursor._directory('a',create=True)
|
|
217
|
+
os.close(fd)
|
|
218
|
+
assert swapped
|
|
219
|
+
assert list((outside/'a').iterdir())==[]
|