From f2d4930f5e2585eb07ba3ae6777723a6c6ecff85 Mon Sep 17 00:00:00 2001 From: Dat Date: Thu, 19 Mar 2026 13:12:07 +0100 Subject: [PATCH 1/7] Add live conversation signposts --- echo/cypress/cypress.env.json | 11 +- .../e2e/suites/34-live-signposts.cy.js | 188 +++++++++ .../collections/conversation_signpost.json | 28 ++ .../fields/conversation/signposts.json | 28 ++ .../signpost_processed_at.json | 44 +++ .../conversation_chunk/signpost_ready_at.json | 44 +++ .../conversation_signpost/category.json | 63 +++ .../conversation_signpost/confidence.json | 44 +++ .../conversation_id.json | 49 +++ .../conversation_signpost/created_at.json | 46 +++ .../evidence_chunk_id.json | 49 +++ .../conversation_signpost/evidence_quote.json | 44 +++ .../fields/conversation_signpost/id.json | 46 +++ .../fields/conversation_signpost/status.json | 55 +++ .../fields/conversation_signpost/summary.json | 44 +++ .../fields/conversation_signpost/title.json | 44 +++ .../conversation_signpost/updated_at.json | 46 +++ .../project/is_signposting_enabled.json | 46 +++ .../project/signposting_focus_terms.json | 47 +++ .../conversation_id.json | 25 ++ .../evidence_chunk_id.json | 25 ++ .../ConversationSignpostsSection.tsx | 148 +++++++ .../components/conversation/hooks/index.ts | 14 + .../project/ProjectPortalEditor.tsx | 83 ++++ .../src/hooks/useLiveConversationSignposts.ts | 78 ++++ echo/frontend/src/lib/typesDirectus.d.ts | 20 + .../src/routes/project/HostGuidePage.tsx | 175 +++++++++ .../src/routes/project/ProjectRoutes.tsx | 2 + .../ProjectConversationOverview.tsx | 6 + echo/server/dembrane/conversation_utils.py | 45 +++ echo/server/dembrane/coordination.py | 40 ++ echo/server/dembrane/scheduler.py | 8 + echo/server/dembrane/service/conversation.py | 162 +++++++- echo/server/dembrane/signposting.py | 371 ++++++++++++++++++ echo/server/dembrane/tasks.py | 96 ++++- echo/server/dembrane/transcribe.py | 31 +- .../live_conversation_signposts.en.jinja | 48 +++ .../test_conversation_signposting_service.py | 56 +++ echo/server/tests/test_signposting.py | 244 ++++++++++++ echo/server/tests/test_tasks_webhook.py | 116 +++++- 40 files changed, 2750 insertions(+), 9 deletions(-) create mode 100644 echo/cypress/e2e/suites/34-live-signposts.cy.js create mode 100644 echo/directus/sync/snapshot/collections/conversation_signpost.json create mode 100644 echo/directus/sync/snapshot/fields/conversation/signposts.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_chunk/signpost_processed_at.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_chunk/signpost_ready_at.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/category.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/confidence.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/conversation_id.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/created_at.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/evidence_chunk_id.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/evidence_quote.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/id.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/status.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/summary.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/title.json create mode 100644 echo/directus/sync/snapshot/fields/conversation_signpost/updated_at.json create mode 100644 echo/directus/sync/snapshot/fields/project/is_signposting_enabled.json create mode 100644 echo/directus/sync/snapshot/fields/project/signposting_focus_terms.json create mode 100644 echo/directus/sync/snapshot/relations/conversation_signpost/conversation_id.json create mode 100644 echo/directus/sync/snapshot/relations/conversation_signpost/evidence_chunk_id.json create mode 100644 echo/frontend/src/components/conversation/ConversationSignpostsSection.tsx create mode 100644 echo/frontend/src/hooks/useLiveConversationSignposts.ts create mode 100644 echo/server/dembrane/signposting.py create mode 100644 echo/server/prompt_templates/live_conversation_signposts.en.jinja create mode 100644 echo/server/tests/service/test_conversation_signposting_service.py create mode 100644 echo/server/tests/test_signposting.py diff --git a/echo/cypress/cypress.env.json b/echo/cypress/cypress.env.json index e2b5ffe7..6ef37846 100644 --- a/echo/cypress/cypress.env.json +++ b/echo/cypress/cypress.env.json @@ -36,5 +36,14 @@ "email": "charugundla.vipul6009@gmail.com", "password": "test@1234" } + }, + "local": { + "dashboardUrl": "http://localhost:5173/", + "portalUrl": "http://localhost:5174/", + "directusUrl": "http://localhost:8055", + "auth": { + "email": "admin@dembrane.com", + "password": "admin" + } } -} \ No newline at end of file +} diff --git a/echo/cypress/e2e/suites/34-live-signposts.cy.js b/echo/cypress/e2e/suites/34-live-signposts.cy.js new file mode 100644 index 00000000..0e5413d7 --- /dev/null +++ b/echo/cypress/e2e/suites/34-live-signposts.cy.js @@ -0,0 +1,188 @@ +import { loginToApp, logout } from "../../support/functions/login"; +import { openPortalEditor } from "../../support/functions/portal"; +import { createProject, deleteProject } from "../../support/functions/project"; +import { openSettingsMenu } from "../../support/functions/settings"; + +describe("Live Signposts", () => { + const directusUrl = (Cypress.env("directusUrl") || "http://localhost:8055").replace( + /\/$/, + "", + ); + + const getFocusTermsTextarea = () => + cy + .get('[data-testid="portal-editor-signposting-focus-terms-textarea"]') + .then(($element) => { + const $textarea = $element.is("textarea") + ? $element + : $element.find("textarea").first(); + return cy.wrap($textarea); + }); + + const toggleLiveSignposting = (enable = true) => { + cy.get('[data-testid="portal-editor-signposting-switch"]') + .scrollIntoView() + .should("exist") + .then(($input) => { + const $label = $input.closest("label"); + const isChecked = $input.is(":checked"); + + if ((enable && !isChecked) || (!enable && isChecked)) { + cy.wrap($label).click({ force: true }); + } + }); + + cy.get('[data-testid="portal-editor-signposting-switch"]').should( + enable ? "be.checked" : "not.be.checked", + ); + }; + + const loginToDirectus = () => + cy + .request("POST", `${directusUrl}/auth/login`, { + email: Cypress.env("auth").email, + password: Cypress.env("auth").password, + }) + .its("body.data.access_token"); + + const seedConversationWithSignposts = ({ locale, projectId }) => { + const suffix = Cypress._.random(1000, 9999); + const signpostTitle = `Transit affordability ${suffix}`; + const signpostSummary = + "Participants keep returning to the rising cost of buses and trains."; + const signpostQuote = "Public transport is becoming too expensive for families."; + + return loginToDirectus().then((accessToken) => { + const headers = { + Authorization: `Bearer ${accessToken}`, + }; + + return cy + .request({ + body: { + is_finished: false, + participant_name: `Signpost Participant ${suffix}`, + project_id: projectId, + source: "PORTAL_TEXT", + }, + headers, + method: "POST", + url: `${directusUrl}/items/conversation`, + }) + .then((conversationResponse) => { + const conversationId = conversationResponse.body.data.id; + const timestamp = new Date().toISOString(); + + return cy + .request({ + body: { + conversation_id: conversationId, + signpost_processed_at: timestamp, + signpost_ready_at: timestamp, + source: "PORTAL_TEXT", + timestamp, + transcript: + "People agree that public transport costs are rising quickly.", + }, + headers, + method: "POST", + url: `${directusUrl}/items/conversation_chunk`, + }) + .then((chunkResponse) => { + const chunkId = chunkResponse.body.data.id; + + return cy + .request({ + body: { + category: "theme", + confidence: 0.92, + conversation_id: conversationId, + evidence_chunk_id: chunkId, + evidence_quote: signpostQuote, + status: "active", + summary: signpostSummary, + title: signpostTitle, + }, + headers, + method: "POST", + url: `${directusUrl}/items/conversation_signpost`, + }) + .then((signpostResponse) => ({ + conversationId, + locale, + projectId, + signpostId: signpostResponse.body.data.id, + signpostQuote, + signpostSummary, + signpostTitle, + })); + }); + }); + }); + }; + + it("shows seeded signposts in portal settings, conversation overview, and host guide", () => { + let projectId; + let locale = "en-US"; + + loginToApp(); + createProject(); + + cy.location("pathname").then((pathname) => { + const segments = pathname.split("/").filter(Boolean); + projectId = segments[segments.indexOf("projects") + 1]; + locale = segments[0] || locale; + }); + + openPortalEditor(); + toggleLiveSignposting(true); + getFocusTermsTextarea() + .scrollIntoView() + .clear() + .type("affordability{enter}public transport"); + cy.wait(3000); + + cy.reload(); + openPortalEditor(); + cy.get('[data-testid="portal-editor-signposting-switch"]').should("be.checked"); + getFocusTermsTextarea().should( + "have.value", + "affordability\npublic transport", + ); + + cy.then(() => seedConversationWithSignposts({ locale, projectId })).then( + ({ conversationId, signpostId, signpostQuote, signpostSummary, signpostTitle }) => { + cy.visit( + `/${locale}/projects/${projectId}/conversation/${conversationId}/overview`, + ); + + cy.get('[data-testid="conversation-signposts-section"]', { + timeout: 20000, + }).should("be.visible"); + cy.get(`[data-testid="conversation-signpost-card-${signpostId}"]`) + .should("contain.text", signpostTitle) + .and("contain.text", signpostSummary) + .and("contain.text", signpostQuote); + + cy.visit(`/${locale}/projects/${projectId}/host-guide`); + + cy.get('[data-testid="host-guide-live-signposts-panel"]', { + timeout: 30000, + }).should("be.visible"); + cy.get(`[data-testid="host-guide-live-signpost-${signpostId}"]`, { + timeout: 30000, + }) + .should("contain.text", signpostTitle) + .and("contain.text", signpostSummary); + }, + ); + + cy.then(() => { + cy.visit(`/${locale}/projects/${projectId}/overview`); + deleteProject(projectId); + }); + + openSettingsMenu(); + logout(); + }); +}); diff --git a/echo/directus/sync/snapshot/collections/conversation_signpost.json b/echo/directus/sync/snapshot/collections/conversation_signpost.json new file mode 100644 index 00000000..1a8d59c4 --- /dev/null +++ b/echo/directus/sync/snapshot/collections/conversation_signpost.json @@ -0,0 +1,28 @@ +{ + "collection": "conversation_signpost", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "conversation_signpost", + "color": null, + "display_template": "{{title}}", + "group": null, + "hidden": false, + "icon": null, + "item_duplication_fields": null, + "note": null, + "preview_url": null, + "singleton": false, + "sort": 14, + "sort_field": null, + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "conversation_signpost" + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation/signposts.json b/echo/directus/sync/snapshot/fields/conversation/signposts.json new file mode 100644 index 00000000..bc1b4ea6 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation/signposts.json @@ -0,0 +1,28 @@ +{ + "collection": "conversation", + "field": "signposts", + "type": "alias", + "meta": { + "collection": "conversation", + "conditions": null, + "display": null, + "display_options": null, + "field": "signposts", + "group": null, + "hidden": false, + "interface": "list-o2m", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 28, + "special": [ + "o2m" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_chunk/signpost_processed_at.json b/echo/directus/sync/snapshot/fields/conversation_chunk/signpost_processed_at.json new file mode 100644 index 00000000..74b580e2 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_chunk/signpost_processed_at.json @@ -0,0 +1,44 @@ +{ + "collection": "conversation_chunk", + "field": "signpost_processed_at", + "type": "timestamp", + "meta": { + "collection": "conversation_chunk", + "conditions": null, + "display": null, + "display_options": null, + "field": "signpost_processed_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 26, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "signpost_processed_at", + "table": "conversation_chunk", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_chunk/signpost_ready_at.json b/echo/directus/sync/snapshot/fields/conversation_chunk/signpost_ready_at.json new file mode 100644 index 00000000..241dc944 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_chunk/signpost_ready_at.json @@ -0,0 +1,44 @@ +{ + "collection": "conversation_chunk", + "field": "signpost_ready_at", + "type": "timestamp", + "meta": { + "collection": "conversation_chunk", + "conditions": null, + "display": null, + "display_options": null, + "field": "signpost_ready_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 25, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "signpost_ready_at", + "table": "conversation_chunk", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/category.json b/echo/directus/sync/snapshot/fields/conversation_signpost/category.json new file mode 100644 index 00000000..c88c8dc6 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/category.json @@ -0,0 +1,63 @@ +{ + "collection": "conversation_signpost", + "field": "category", + "type": "string", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "category", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": null, + "options": { + "choices": [ + { + "text": "Agreement", + "value": "agreement" + }, + { + "text": "Disagreement", + "value": "disagreement" + }, + { + "text": "Tension", + "value": "tension" + }, + { + "text": "Theme", + "value": "theme" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "category", + "table": "conversation_signpost", + "data_type": "character varying", + "default_value": null, + "max_length": 32, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/confidence.json b/echo/directus/sync/snapshot/fields/conversation_signpost/confidence.json new file mode 100644 index 00000000..e0a1450e --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/confidence.json @@ -0,0 +1,44 @@ +{ + "collection": "conversation_signpost", + "field": "confidence", + "type": "float", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "confidence", + "group": null, + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 11, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "confidence", + "table": "conversation_signpost", + "data_type": "real", + "default_value": null, + "max_length": null, + "numeric_precision": 24, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/conversation_id.json b/echo/directus/sync/snapshot/fields/conversation_signpost/conversation_id.json new file mode 100644 index 00000000..5c097a52 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/conversation_id.json @@ -0,0 +1,49 @@ +{ + "collection": "conversation_signpost", + "field": "conversation_id", + "type": "uuid", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "conversation_id", + "group": null, + "hidden": false, + "interface": "select-dropdown-m2o", + "note": null, + "options": { + "enableLink": true, + "template": "{{participant_name}}.{{project_id.name}}" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 4, + "special": [ + "m2o" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "conversation_id", + "table": "conversation_signpost", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "conversation", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/created_at.json b/echo/directus/sync/snapshot/fields/conversation_signpost/created_at.json new file mode 100644 index 00000000..c44abeac --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/created_at.json @@ -0,0 +1,46 @@ +{ + "collection": "conversation_signpost", + "field": "created_at", + "type": "timestamp", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "created_at", + "group": null, + "hidden": false, + "interface": null, + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 2, + "special": [ + "date-created" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "created_at", + "table": "conversation_signpost", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/evidence_chunk_id.json b/echo/directus/sync/snapshot/fields/conversation_signpost/evidence_chunk_id.json new file mode 100644 index 00000000..19f7a528 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/evidence_chunk_id.json @@ -0,0 +1,49 @@ +{ + "collection": "conversation_signpost", + "field": "evidence_chunk_id", + "type": "uuid", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "evidence_chunk_id", + "group": null, + "hidden": false, + "interface": "select-dropdown-m2o", + "note": null, + "options": { + "enableLink": true, + "template": "{{timestamp}}" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": [ + "m2o" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "evidence_chunk_id", + "table": "conversation_signpost", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "conversation_chunk", + "foreign_key_column": "id" + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/evidence_quote.json b/echo/directus/sync/snapshot/fields/conversation_signpost/evidence_quote.json new file mode 100644 index 00000000..4804aae7 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/evidence_quote.json @@ -0,0 +1,44 @@ +{ + "collection": "conversation_signpost", + "field": "evidence_quote", + "type": "text", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "evidence_quote", + "group": null, + "hidden": false, + "interface": "input-multiline", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 9, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "evidence_quote", + "table": "conversation_signpost", + "data_type": "text", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/id.json b/echo/directus/sync/snapshot/fields/conversation_signpost/id.json new file mode 100644 index 00000000..f16f7d36 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/id.json @@ -0,0 +1,46 @@ +{ + "collection": "conversation_signpost", + "field": "id", + "type": "uuid", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": false, + "interface": null, + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "conversation_signpost", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/status.json b/echo/directus/sync/snapshot/fields/conversation_signpost/status.json new file mode 100644 index 00000000..82c797db --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/status.json @@ -0,0 +1,55 @@ +{ + "collection": "conversation_signpost", + "field": "status", + "type": "string", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "status", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": null, + "options": { + "choices": [ + { + "text": "Active", + "value": "active" + }, + { + "text": "Resolved", + "value": "resolved" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 10, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "status", + "table": "conversation_signpost", + "data_type": "character varying", + "default_value": "active", + "max_length": 32, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/summary.json b/echo/directus/sync/snapshot/fields/conversation_signpost/summary.json new file mode 100644 index 00000000..0094c640 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/summary.json @@ -0,0 +1,44 @@ +{ + "collection": "conversation_signpost", + "field": "summary", + "type": "text", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "summary", + "group": null, + "hidden": false, + "interface": "input-multiline", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 8, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "summary", + "table": "conversation_signpost", + "data_type": "text", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/title.json b/echo/directus/sync/snapshot/fields/conversation_signpost/title.json new file mode 100644 index 00000000..058ca8a4 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/title.json @@ -0,0 +1,44 @@ +{ + "collection": "conversation_signpost", + "field": "title", + "type": "text", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "title", + "group": null, + "hidden": false, + "interface": null, + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 7, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "title", + "table": "conversation_signpost", + "data_type": "text", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation_signpost/updated_at.json b/echo/directus/sync/snapshot/fields/conversation_signpost/updated_at.json new file mode 100644 index 00000000..b002e962 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation_signpost/updated_at.json @@ -0,0 +1,46 @@ +{ + "collection": "conversation_signpost", + "field": "updated_at", + "type": "timestamp", + "meta": { + "collection": "conversation_signpost", + "conditions": null, + "display": null, + "display_options": null, + "field": "updated_at", + "group": null, + "hidden": false, + "interface": null, + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 3, + "special": [ + "date-updated" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "updated_at", + "table": "conversation_signpost", + "data_type": "timestamp with time zone", + "default_value": "CURRENT_TIMESTAMP", + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project/is_signposting_enabled.json b/echo/directus/sync/snapshot/fields/project/is_signposting_enabled.json new file mode 100644 index 00000000..940e3ec2 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project/is_signposting_enabled.json @@ -0,0 +1,46 @@ +{ + "collection": "project", + "field": "is_signposting_enabled", + "type": "boolean", + "meta": { + "collection": "project", + "conditions": null, + "display": null, + "display_options": null, + "field": "is_signposting_enabled", + "group": null, + "hidden": false, + "interface": "boolean", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 34, + "special": [ + "cast-boolean" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "is_signposting_enabled", + "table": "project", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/project/signposting_focus_terms.json b/echo/directus/sync/snapshot/fields/project/signposting_focus_terms.json new file mode 100644 index 00000000..42b4dcf0 --- /dev/null +++ b/echo/directus/sync/snapshot/fields/project/signposting_focus_terms.json @@ -0,0 +1,47 @@ +{ + "collection": "project", + "field": "signposting_focus_terms", + "type": "text", + "meta": { + "collection": "project", + "conditions": null, + "display": null, + "display_options": null, + "field": "signposting_focus_terms", + "group": null, + "hidden": false, + "interface": "input-multiline", + "note": null, + "options": { + "clear": true, + "trim": true + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 35, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "signposting_focus_terms", + "table": "project", + "data_type": "text", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/relations/conversation_signpost/conversation_id.json b/echo/directus/sync/snapshot/relations/conversation_signpost/conversation_id.json new file mode 100644 index 00000000..08e7aded --- /dev/null +++ b/echo/directus/sync/snapshot/relations/conversation_signpost/conversation_id.json @@ -0,0 +1,25 @@ +{ + "collection": "conversation_signpost", + "field": "conversation_id", + "related_collection": "conversation", + "meta": { + "junction_field": null, + "many_collection": "conversation_signpost", + "many_field": "conversation_id", + "one_allowed_collections": null, + "one_collection": "conversation", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": "signposts", + "sort_field": null + }, + "schema": { + "table": "conversation_signpost", + "column": "conversation_id", + "foreign_key_table": "conversation", + "foreign_key_column": "id", + "constraint_name": "conversation_signpost_conversation_id_foreign", + "on_update": "NO ACTION", + "on_delete": "CASCADE" + } +} diff --git a/echo/directus/sync/snapshot/relations/conversation_signpost/evidence_chunk_id.json b/echo/directus/sync/snapshot/relations/conversation_signpost/evidence_chunk_id.json new file mode 100644 index 00000000..e6d6fb4d --- /dev/null +++ b/echo/directus/sync/snapshot/relations/conversation_signpost/evidence_chunk_id.json @@ -0,0 +1,25 @@ +{ + "collection": "conversation_signpost", + "field": "evidence_chunk_id", + "related_collection": "conversation_chunk", + "meta": { + "junction_field": null, + "many_collection": "conversation_signpost", + "many_field": "evidence_chunk_id", + "one_allowed_collections": null, + "one_collection": "conversation_chunk", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "conversation_signpost", + "column": "evidence_chunk_id", + "foreign_key_table": "conversation_chunk", + "foreign_key_column": "id", + "constraint_name": "conversation_signpost_evidence_chunk_id_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } +} diff --git a/echo/frontend/src/components/conversation/ConversationSignpostsSection.tsx b/echo/frontend/src/components/conversation/ConversationSignpostsSection.tsx new file mode 100644 index 00000000..ced7f046 --- /dev/null +++ b/echo/frontend/src/components/conversation/ConversationSignpostsSection.tsx @@ -0,0 +1,148 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge, Group, Paper, Stack, Text, Title } from "@mantine/core"; +import { formatDistance } from "date-fns"; +import { testId } from "@/lib/testUtils"; + +const SIGNPOST_CATEGORY_ORDER: Array< + NonNullable +> = ["agreement", "disagreement", "tension", "theme"]; + +const getCategoryColor = (category: ConversationSignpost["category"]) => { + switch (category) { + case "agreement": + return "teal"; + case "disagreement": + return "red"; + case "tension": + return "orange"; + case "theme": + default: + return "blue"; + } +}; + +const getCategoryLabel = (category: ConversationSignpost["category"]) => { + switch (category) { + case "agreement": + return t`Agreement`; + case "disagreement": + return t`Disagreement`; + case "tension": + return t`Tension`; + case "theme": + default: + return t`Theme`; + } +}; + +const getUpdatedLabel = (updatedAt: string | null) => { + if (!updatedAt) { + return t`Updated just now`; + } + + const date = new Date(updatedAt); + if (Number.isNaN(date.getTime())) { + return t`Updated just now`; + } + + return t`Updated ${formatDistance(date, new Date(), { addSuffix: true })}`; +}; + +type ConversationSignpostsSectionProps = { + signposts: ConversationSignpost[]; +}; + +export const ConversationSignpostsSection = ({ + signposts, +}: ConversationSignpostsSectionProps) => { + const activeSignposts = signposts.filter((signpost) => signpost.status === "active"); + + if (activeSignposts.length === 0) { + return null; + } + + return ( + + + + <Trans>Signposts</Trans> + + + + Live themes, agreements, disagreements, and tensions surfaced + from the latest transcript chunks. + + + + + {SIGNPOST_CATEGORY_ORDER.map((category) => { + const items = activeSignposts + .filter((signpost) => signpost.category === category) + .sort((left, right) => { + const leftTime = new Date( + left.updated_at ?? left.created_at ?? 0, + ).getTime(); + const rightTime = new Date( + right.updated_at ?? right.created_at ?? 0, + ).getTime(); + return rightTime - leftTime; + }); + + if (items.length === 0) { + return null; + } + + return ( + + + + {getCategoryLabel(category)} + + + {t`${items.length} live`} + + + + {items.map((signpost) => ( + + + + {signpost.title} + + {getUpdatedLabel(signpost.updated_at)} + + + {signpost.summary && ( + {signpost.summary} + )} + {signpost.evidence_quote && ( + + "{signpost.evidence_quote}" + + )} + + + ))} + + ); + })} + + ); +}; diff --git a/echo/frontend/src/components/conversation/hooks/index.ts b/echo/frontend/src/components/conversation/hooks/index.ts index 76f9946c..960001e5 100644 --- a/echo/frontend/src/components/conversation/hooks/index.ts +++ b/echo/frontend/src/components/conversation/hooks/index.ts @@ -882,6 +882,20 @@ export const useConversationById = ({ }, ], }, + { + signposts: [ + "id", + "category", + "title", + "summary", + "evidence_quote", + "status", + "confidence", + "created_at", + "updated_at", + "evidence_chunk_id", + ], + }, ...(loadConversationChunks ? [ { diff --git a/echo/frontend/src/components/project/ProjectPortalEditor.tsx b/echo/frontend/src/components/project/ProjectPortalEditor.tsx index 87064720..c0c97e21 100644 --- a/echo/frontend/src/components/project/ProjectPortalEditor.tsx +++ b/echo/frontend/src/components/project/ProjectPortalEditor.tsx @@ -78,9 +78,11 @@ const FormSchema = z.object({ get_reply_prompt: z.string(), is_get_reply_enabled: z.boolean(), is_project_notification_subscription_allowed: z.boolean(), + is_signposting_enabled: z.boolean(), is_verify_enabled: z.boolean(), is_verify_on_finish_enabled: z.boolean(), language: z.enum(["en", "nl", "de", "fr", "es", "it"]), + signposting_focus_terms: z.string(), verification_topics: z.array(z.string()), }); @@ -300,9 +302,11 @@ const ProjectPortalEditorComponent: React.FC = ({ is_get_reply_enabled: project.is_get_reply_enabled ?? false, is_project_notification_subscription_allowed: project.is_project_notification_subscription_allowed ?? false, + is_signposting_enabled: project.is_signposting_enabled ?? false, is_verify_enabled: project.is_verify_enabled ?? false, is_verify_on_finish_enabled: project.is_verify_on_finish_enabled ?? false, language: projectLanguageCode, + signposting_focus_terms: project.signposting_focus_terms ?? "", verification_topics: selectedTopicDefaults, }; }, [project.id, projectLanguageCode, selectedTopicDefaults]); @@ -340,6 +344,11 @@ const ProjectPortalEditorComponent: React.FC = ({ name: "is_verify_enabled", }); + const watchedSignpostingEnabled = useWatch({ + control, + name: "is_signposting_enabled", + }); + const watchedAskForEmail = useWatch({ control, name: "default_conversation_ask_for_participant_email", @@ -1454,6 +1463,80 @@ const ProjectPortalEditorComponent: React.FC = ({ /> + + + + <Trans>Signposts</Trans> + + + + + + Generate live host-facing signposts from each + conversation as transcripts arrive. In v1, signposts are + grouped into agreement, disagreement, tension, and + theme. + + + ( + + } + checked={field.value} + onChange={(e) => + field.onChange(e.currentTarget.checked) + } + {...testId("portal-editor-signposting-switch")} + /> + )} + /> + ( +