From 37bfffd11883f4c67ebbbb9dd3a11fe34df508ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 04:15:04 +0000 Subject: [PATCH] feat(org): Phase 3 in-host Reflections org-ui slice Add a shared ReflectionsPage under org-ui, mount it at /organization/reflections, and persist CRUD on /api/org-module/reflections in FREEOS_HOME/org. No sidecar, Chat, Agents, Settings, or Workspace in this slice. --- CHANGELOG.md | 1 + dashboard/src/locales/en.json | 6 +- dashboard/src/locales/zh.json | 6 +- dashboard/src/org-ui/api/createClient.test.ts | 15 + dashboard/src/org-ui/api/createClient.ts | 107 ++++ dashboard/src/org-ui/contract.ts | 1 + dashboard/src/org-ui/index.ts | 8 + .../reflections/ReflectionsPage.module.css | 157 ++++++ .../reflections/ReflectionsPage.test.tsx | 82 +++ .../pages/reflections/ReflectionsPage.tsx | 467 ++++++++++++++++++ .../src/org-ui/pages/reflections/labels.ts | 83 ++++ .../src/pages/Organization/index.test.tsx | 13 + dashboard/src/pages/Organization/index.tsx | 17 + .../src/pages/Organization/orgPathTabs.tsx | 13 +- .../pages/Organization/reflections/index.tsx | 46 ++ dashboard/src/routes/controlAdminPath.test.ts | 4 + dashboard/src/routes/index.tsx | 8 + docs/architecture-integration.md | 2 +- docs/org-merge-plan.md | 43 +- scripts/org-export/README.md | 6 +- src/octop/api/openapi_meta.py | 2 +- src/octop/api/routers/org_module.py | 2 + src/octop/api/routers/org_reflections.py | 188 +++++++ src/octop/cli/commands/org.py | 5 +- src/octop/i18n/en.json | 8 + src/octop/i18n/zh.json | 8 + src/octop/modules/org_os/contract.py | 1 + src/octop/modules/org_os/export_standalone.py | 28 +- .../modules/org_os/reflections/__init__.py | 13 + src/octop/modules/org_os/reflections/store.py | 223 +++++++++ tests/unit/cli/test_org_export_standalone.py | 8 + tests/unit/test_org_reflections.py | 176 +++++++ 32 files changed, 1731 insertions(+), 16 deletions(-) create mode 100644 dashboard/src/org-ui/pages/reflections/ReflectionsPage.module.css create mode 100644 dashboard/src/org-ui/pages/reflections/ReflectionsPage.test.tsx create mode 100644 dashboard/src/org-ui/pages/reflections/ReflectionsPage.tsx create mode 100644 dashboard/src/org-ui/pages/reflections/labels.ts create mode 100644 dashboard/src/pages/Organization/reflections/index.tsx create mode 100644 src/octop/api/routers/org_reflections.py create mode 100644 src/octop/modules/org_os/reflections/__init__.py create mode 100644 src/octop/modules/org_os/reflections/store.py create mode 100644 tests/unit/test_org_reflections.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e201028c..30a86093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- Organization Phase 3(反思切片):宿主内 Reflections。Dashboard `/organization/reflections` 使用共享 `dashboard/src/org-ui` 的 `ReflectionsPage`。列表/创建/删除走 `/api/org-module/reflections*`,SQLite 在 `{FREEOS_HOME}/org/reflections.sqlite`。可选 `employee_id` / `task_id` 指向同一租户的目录员工与组织任务。这是组织复盘,不是 Chat,也不是第二套技能运行时。`freeos org export-standalone` 同时列出 Reflections。未迁:Chat、边车技能统计、边车部署铁律样例、其余 Open-12。 - Organization Phase 3(任务切片):宿主内 Tasks。Dashboard `/organization/tasks` 与 `/organization/tasks/:id` 使用共享 `dashboard/src/org-ui` 的 `TasksPage` / `TaskDetailPage`。CRUD、状态流转、子任务与人工评论走 `/api/org-module/tasks*`,SQLite 在 `{FREEOS_HOME}/org/tasks.sqlite`。这是组织待办,不是 Octop cron,也不是项目/Chat 对话。`freeos org export-standalone` 同时列出 Tasks。未迁:Chat、附件上传、边车任务库、其余 Open-12。 - Organization Phase 3(知识切片):宿主内 Knowledge。Dashboard `/organization/knowledge` 使用共享 `dashboard/src/org-ui` 的 `KnowledgePage`。列表/详情/笔记走 `/api/org-module/knowledge*`,包装已有 `KnowledgeService`(Octop `knowledge_bases` / `knowledge_documents`),与 Chat 检索同一套行。不克隆 openXYOS sidecar notes/files DB。上传、文件夹、嵌入模型仍在 `/knowledge-bases`。`freeos org export-standalone` 同时列出 Knowledge。未迁:Chat、边车知识库、其余 Open-12。 - Organization Phase 3(技能切片):宿主内 Skills。Dashboard `/organization/skills` 使用共享 `dashboard/src/org-ui` 的 `SkillsPage`。列表/详情走 `GET /api/org-module/skills` 与 `GET /api/org-module/skills/{slug}`,生成/发布仍是已有 `POST …/generate|publish`(`{FREEOS_HOME}/org-skills`,`skill_bridge`)。宿主 skill packages 只读列出,编辑仍在「个性化 → 技能包」。这不是第二套技能运行时。`freeos org export-standalone` 同时列出 Skills。未迁:Chat、边车市场/插件中心、其余 Open-12。 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index af0fc2cb..f672ec99 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -189,6 +189,7 @@ "navGovernance": "Governance", "navKnowledge": "Knowledge", "navTasks": "Tasks", + "navReflections": "Reflections", "announcementsTitle": "Announcements", "announcementsBody": "Publish notices and track who has read them. This page lives in the host — no sidecar or second port.", "openAnnouncements": "Open announcements", @@ -209,7 +210,10 @@ "openKnowledge": "Open knowledge", "tasksTitle": "Tasks", "tasksBody": "Organization work items on the host org store (FREEOS_HOME/org). Not Octop cron, not project chat, and not a sidecar.", - "openTasks": "Open tasks" + "openTasks": "Open tasks", + "reflectionsTitle": "Reflections", + "reflectionsBody": "Capture lessons and reusable experience on the host org store (FREEOS_HOME/org). Not a sidecar and not Chat.", + "openReflections": "Open reflections" }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 29c6a56c..b55ce0b8 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -189,6 +189,7 @@ "navGovernance": "治理", "navKnowledge": "知识", "navTasks": "任务", + "navReflections": "反思", "announcementsTitle": "通知公告", "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", "openAnnouncements": "打开通知公告", @@ -209,7 +210,10 @@ "openKnowledge": "打开知识", "tasksTitle": "任务", "tasksBody": "宿主组织库里的待办(FREEOS_HOME/org)。不是 Octop 定时任务,也不是项目对话或边车。", - "openTasks": "打开任务" + "openTasks": "打开任务", + "reflectionsTitle": "反思引擎", + "reflectionsBody": "在宿主组织库里整理复盘与经验(FREEOS_HOME/org)。不是边车,也不是对话。", + "openReflections": "打开反思" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index 08051e3a..ab78ca34 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -69,6 +69,18 @@ describe("createOrgApiClient", () => { data: [{ id: 3, title: "Ship Tasks slice", status: "todo" }], }; } + if (path === "/org-module/reflections") { + return { + success: true, + data: [ + { + id: 2, + reflection_type: "error_learning", + failure_reasons: "const dead zone", + }, + ], + }; + } throw new Error(`unexpected ${path}`); }); const client = createOrgApiClient({ fetchJson }); @@ -86,6 +98,9 @@ describe("createOrgApiClient", () => { expect((await client.skills.list()).skills[0]?.slug).toBe("org-employees"); expect((await client.knowledge.list()).bases[0]?.name).toBe("Policies"); expect((await client.tasks.list())[0]?.title).toBe("Ship Tasks slice"); + expect((await client.reflections.list())[0]?.failure_reasons).toBe( + "const dead zone", + ); expect(fetchJson).toHaveBeenCalled(); }); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index 931d5fc7..5f626b4d 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -445,6 +445,62 @@ export interface OrgTasksClient { addComment(id: number, content: string): Promise; } +export type OrgReflectionType = + | "task_completion" + | "error_learning" + | "knowledge_capture" + | "improvement"; + +export interface OrgReflection { + id: number; + employee_id: number; + task_id?: number | null; + reflection_type: string; + success_factors?: string | null; + failure_reasons?: string | null; + knowledge_gaps?: string | null; + improvement_plans?: string | null; + extracted_skills?: string | null; + learned_knowledge?: string | null; + importance_score: number; + created_at?: string; +} + +export interface OrgReflectionStats { + total: number; + task_completion: number; + error_learning: number; + knowledge_capture: number; + improvement: number; +} + +export interface OrgReflectionWrite { + employee_id?: number | null; + task_id?: number | null; + reflection_type?: string; + success_factors?: string; + failure_reasons?: string; + knowledge_gaps?: string; + improvement_plans?: string; + extracted_skills?: string; + learned_knowledge?: string; + importance_score?: number; +} + +export interface OrgReflectionListParams { + employee_id?: number; + type?: string; + search?: string; +} + +export interface OrgReflectionsClient { + list(params?: OrgReflectionListParams): Promise; + stats(): Promise; + get(id: number): Promise; + create(body: OrgReflectionWrite): Promise; + remove(id: number): Promise; +} + export interface OrgApiClient { announcements: OrgAnnouncementsClient; org: OrgChartClient; @@ -453,6 +509,7 @@ export interface OrgApiClient { skills: OrgSkillsClient; knowledge: OrgKnowledgeClient; tasks: OrgTasksClient; + reflections: OrgReflectionsClient; } async function unwrap(payload: OrgEnvelope): Promise { @@ -474,6 +531,7 @@ export function createOrgApiClient(opts: { skillsPrefix?: string; knowledgePrefix?: string; tasksPrefix?: string; + reflectionsPrefix?: string; }): OrgApiClient { const prefix = opts.announcementsPrefix ?? "/org-module/announcements"; const orgPrefix = opts.orgPrefix ?? "/org-module/org"; @@ -481,6 +539,7 @@ export function createOrgApiClient(opts: { const skillsPrefix = opts.skillsPrefix ?? "/org-module/skills"; const knowledgePrefix = opts.knowledgePrefix ?? "/org-module/knowledge"; const tasksPrefix = opts.tasksPrefix ?? "/org-module/tasks"; + const reflectionsPrefix = opts.reflectionsPrefix ?? "/org-module/reflections"; const fetchJson = opts.fetchJson; const announcements: OrgAnnouncementsClient = { @@ -879,6 +938,53 @@ export function createOrgApiClient(opts: { }, }; + const reflections: OrgReflectionsClient = { + async list(params = {}) { + const query = new URLSearchParams(); + if (params.employee_id != null) { + query.set("employee_id", String(params.employee_id)); + } + if (params.type) query.set("type", params.type); + if (params.search) query.set("search", params.search); + const suffix = query.toString() ? `?${query.toString()}` : ""; + return unwrap( + await fetchJson>( + `${reflectionsPrefix}${suffix}`, + ), + ); + }, + async stats() { + return unwrap( + await fetchJson>( + `${reflectionsPrefix}/stats`, + ), + ); + }, + async get(id) { + return unwrap( + await fetchJson>( + `${reflectionsPrefix}/${id}`, + ), + ); + }, + async create(body) { + return unwrap( + await fetchJson>(reflectionsPrefix, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + }, + async remove(id) { + await unwrap( + await fetchJson>(`${reflectionsPrefix}/${id}`, { + method: "DELETE", + }), + ); + }, + }; + return { announcements, org, @@ -887,5 +993,6 @@ export function createOrgApiClient(opts: { skills, knowledge, tasks, + reflections, }; } diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts index 79c9bee8..e939c04c 100644 --- a/dashboard/src/org-ui/contract.ts +++ b/dashboard/src/org-ui/contract.ts @@ -7,6 +7,7 @@ export const SHARED_ORG_UI_MODULES = [ "governance", "knowledge", "tasks", + "reflections", ] as const; export type SharedOrgUiModule = (typeof SHARED_ORG_UI_MODULES)[number]; diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index 49e9ae8d..f7673a4f 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -42,6 +42,12 @@ export type { OrgTaskSubtask, OrgTaskWrite, OrgTasksClient, + OrgReflection, + OrgReflectionListParams, + OrgReflectionStats, + OrgReflectionType, + OrgReflectionWrite, + OrgReflectionsClient, OrgSkill, OrgSkillCatalogRow, OrgSkillGenerateResult, @@ -67,6 +73,8 @@ export { TasksPage } from "./pages/tasks/TasksPage"; export type { TasksPageProps } from "./pages/tasks/TasksPage"; export { TaskDetailPage } from "./pages/tasks/TaskDetailPage"; export type { TaskDetailPageProps } from "./pages/tasks/TaskDetailPage"; +export { ReflectionsPage } from "./pages/reflections/ReflectionsPage"; +export type { ReflectionsPageProps } from "./pages/reflections/ReflectionsPage"; export type { IdentityBridge, OrgFetcher, diff --git a/dashboard/src/org-ui/pages/reflections/ReflectionsPage.module.css b/dashboard/src/org-ui/pages/reflections/ReflectionsPage.module.css new file mode 100644 index 00000000..7b15ef45 --- /dev/null +++ b/dashboard/src/org-ui/pages/reflections/ReflectionsPage.module.css @@ -0,0 +1,157 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 8px 4px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.titleRow { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 650; +} + +.subtitle { + margin: 4px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; + max-width: 640px; + line-height: 1.5; +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.stat { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 10px; + padding: 12px 14px; + background: var(--fn-bg-container, #fff); +} + +.statValue { + font-size: 22px; + font-weight: 650; + line-height: 1.2; +} + +.statLabel { + margin-top: 4px; + font-size: 12px; + color: var(--fn-text-secondary, #667085); +} + +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.hint { + margin: 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; +} + +.list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.card { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 14px; + background: var(--fn-bg-container, #fff); +} + +.cardHead { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.cardMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.cardTitle { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 8px; +} + +.block { + border-radius: 8px; + padding: 8px 10px; + font-size: 12px; + line-height: 1.5; + background: var(--fn-bg, #f9fafb); +} + +.blockLabel { + font-weight: 600; + margin-bottom: 4px; +} + +.muted { + color: var(--fn-text-secondary, #667085); +} + +.when { + margin: 10px 0 0; + font-size: 12px; + color: var(--fn-text-secondary, #667085); +} + +.empty { + text-align: center; + padding: 48px 12px; + color: var(--fn-text-secondary, #667085); +} + +.rowLine { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 0; +} + +.rowPair { + display: flex; + flex-wrap: wrap; + gap: 12px; +} diff --git a/dashboard/src/org-ui/pages/reflections/ReflectionsPage.test.tsx b/dashboard/src/org-ui/pages/reflections/ReflectionsPage.test.tsx new file mode 100644 index 00000000..70151a24 --- /dev/null +++ b/dashboard/src/org-ui/pages/reflections/ReflectionsPage.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ReflectionsPage } from "./ReflectionsPage"; +import type { OrgReflectionsClient } from "../../api/createClient"; + +function mockClient(): OrgReflectionsClient { + return { + list: vi.fn(async () => [ + { + id: 3, + employee_id: 4, + task_id: 1, + reflection_type: "error_learning", + success_factors: null, + failure_reasons: "const dead zone", + knowledge_gaps: null, + improvement_plans: "re-read after edit", + extracted_skills: null, + learned_knowledge: null, + importance_score: 90, + created_at: "2026-09-19T00:00:00Z", + }, + ]), + stats: vi.fn(async () => ({ + total: 1, + task_completion: 0, + error_learning: 1, + knowledge_capture: 0, + improvement: 0, + })), + get: vi.fn(), + create: vi.fn(async (body) => ({ + id: 9, + employee_id: body.employee_id ?? 0, + task_id: body.task_id ?? null, + reflection_type: body.reflection_type || "task_completion", + success_factors: body.success_factors || null, + failure_reasons: body.failure_reasons || null, + knowledge_gaps: body.knowledge_gaps || null, + improvement_plans: body.improvement_plans || null, + extracted_skills: body.extracted_skills || null, + learned_knowledge: body.learned_knowledge || null, + importance_score: body.importance_score ?? 50, + created_at: "2026-09-19T00:00:00Z", + })), + remove: vi.fn(async () => undefined), + }; +} + +const session = { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, +}; + +describe("ReflectionsPage", () => { + it("renders the shared page without an iframe", async () => { + const client = mockClient(); + render(); + expect(await screen.findByTestId("org-ui-reflections")).toBeInTheDocument(); + expect(screen.getByText("Reflections")).toBeInTheDocument(); + expect(screen.getByText("const dead zone")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.list).toHaveBeenCalled(); + }); + + it("lets a signed-in user create through the injected client", async () => { + const client = mockClient(); + render(); + fireEvent.click(await screen.findByTestId("org-reflections-create")); + fireEvent.change(screen.getByTestId("org-reflections-success"), { + target: { value: "Tests first" }, + }); + fireEvent.click(screen.getByTestId("org-reflections-submit")); + await waitFor(() => { + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ success_factors: "Tests first" }), + ); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/reflections/ReflectionsPage.tsx b/dashboard/src/org-ui/pages/reflections/ReflectionsPage.tsx new file mode 100644 index 00000000..4d84d811 --- /dev/null +++ b/dashboard/src/org-ui/pages/reflections/ReflectionsPage.tsx @@ -0,0 +1,467 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Input, Modal, Select, Space, Tag, Typography } from "antd"; +import { Brain, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; +import type { + OrgReflection, + OrgReflectionsClient, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { reflectionLabels } from "./labels"; +import styles from "./ReflectionsPage.module.css"; + +export interface ReflectionsPageProps { + client: OrgReflectionsClient; + session: OrgSession; + locale: OrgLocale; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +const EMPTY_STATS = { + total: 0, + task_completion: 0, + error_learning: 0, + knowledge_capture: 0, + improvement: 0, +}; + +const TYPE_COLOR: Record = { + task_completion: "green", + error_learning: "red", + knowledge_capture: "blue", + improvement: "gold", +}; + +const EMPTY_FORM = { + employee_id: "", + task_id: "", + reflection_type: "task_completion", + success_factors: "", + failure_reasons: "", + knowledge_gaps: "", + improvement_plans: "", + extracted_skills: "", + learned_knowledge: "", + importance_score: 50, +}; + +function importanceColor(score: number): string { + if (score >= 80) return "red"; + if (score >= 60) return "gold"; + if (score >= 40) return "blue"; + return "default"; +} + +export function ReflectionsPage({ + client, + session: _session, + locale, +}: ReflectionsPageProps) { + const labels = reflectionLabels(locale); + const [items, setItems] = useState([]); + const [stats, setStats] = useState(EMPTY_STATS); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [filterType, setFilterType] = useState("all"); + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [error, setError] = useState(""); + + const typeLabel = (value: string) => + ({ + task_completion: labels.typeTask, + error_learning: labels.typeError, + knowledge_capture: labels.typeKnowledge, + improvement: labels.typeImprovement, + })[value] || value; + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const [rows, nextStats] = await Promise.all([ + client.list({ + type: filterType === "all" ? undefined : filterType, + }), + client.stats(), + ]); + setItems(rows); + setStats(nextStats); + } catch { + setItems([]); + setStats(EMPTY_STATS); + } finally { + setLoading(false); + } + }, [client, filterType]); + + useEffect(() => { + void fetchAll(); + }, [fetchAll]); + + const query = search.trim().toLowerCase(); + const visible = useMemo( + () => + query + ? items.filter((row) => { + const hay = [ + row.success_factors, + row.failure_reasons, + row.knowledge_gaps, + row.improvement_plans, + row.extracted_skills, + row.learned_knowledge, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return hay.includes(query); + }) + : items, + [items, query], + ); + + const openCreate = () => { + setError(""); + setForm(EMPTY_FORM); + setShowCreate(true); + }; + + const submitCreate = async () => { + const body = { + reflection_type: form.reflection_type, + success_factors: form.success_factors.trim() || undefined, + failure_reasons: form.failure_reasons.trim() || undefined, + knowledge_gaps: form.knowledge_gaps.trim() || undefined, + improvement_plans: form.improvement_plans.trim() || undefined, + extracted_skills: form.extracted_skills.trim() || undefined, + learned_knowledge: form.learned_knowledge.trim() || undefined, + importance_score: Number(form.importance_score) || 50, + employee_id: form.employee_id ? Number(form.employee_id) : undefined, + task_id: form.task_id ? Number(form.task_id) : undefined, + }; + const hasText = [ + body.success_factors, + body.failure_reasons, + body.knowledge_gaps, + body.improvement_plans, + body.extracted_skills, + body.learned_knowledge, + ].some(Boolean); + if (!hasText) { + setError(labels.required); + return; + } + try { + await client.create(body); + setShowCreate(false); + await fetchAll(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const remove = async (id: number) => { + Modal.confirm({ + title: labels.confirmDelete, + okText: labels.delete, + cancelText: labels.cancel, + onOk: async () => { + await client.remove(id); + await fetchAll(); + }, + }); + }; + + return ( +
+
+
+ +
+ + {labels.title} + +

{labels.subtitle}

+
+
+ + + + +
+ + {error ? {error} : null} +

{labels.hostHint}

+ +
+
+
{stats.total}
+
{labels.total}
+
+
+
{stats.task_completion}
+
{labels.typeTask}
+
+
+
{stats.error_learning}
+
{labels.typeError}
+
+
+
{stats.knowledge_capture}
+
{labels.typeKnowledge}
+
+
+ +
+ } + value={search} + onChange={(event) => setSearch(event.target.value)} + placeholder={labels.search} + allowClear + data-testid="org-reflections-search" + /> + + setForm((prev) => ({ + ...prev, + employee_id: event.target.value, + })) + } + data-testid="org-reflections-employee" + /> +
+
+ {labels.task} + + setForm((prev) => ({ ...prev, task_id: event.target.value })) + } + data-testid="org-reflections-task" + /> +
+
+
+ {labels.typeTask} + + setForm((prev) => ({ + ...prev, + importance_score: Number(event.target.value), + })) + } + data-testid="org-reflections-importance" + /> +
+
+ {labels.success} + + setForm((prev) => ({ + ...prev, + success_factors: event.target.value, + })) + } + data-testid="org-reflections-success" + /> +
+
+ {labels.failure} + + setForm((prev) => ({ + ...prev, + failure_reasons: event.target.value, + })) + } + data-testid="org-reflections-failure" + /> +
+
+ {labels.plan} + + setForm((prev) => ({ + ...prev, + improvement_plans: event.target.value, + })) + } + data-testid="org-reflections-plan" + /> +
+ + + ); +} diff --git a/dashboard/src/org-ui/pages/reflections/labels.ts b/dashboard/src/org-ui/pages/reflections/labels.ts new file mode 100644 index 00000000..20783658 --- /dev/null +++ b/dashboard/src/org-ui/pages/reflections/labels.ts @@ -0,0 +1,83 @@ +import type { OrgLocale } from "../../shell"; + +const EN = { + title: "Reflections", + subtitle: + "Capture lessons, failures, and reusable experience on the host. Stored under FREEOS_HOME/org — not a sidecar and not Chat.", + hostHint: + "These are organizational reflections. Agent conversation stays on Chat. Extracted skills do not become a second skill runtime.", + search: "Search reflections…", + loading: "Loading reflections…", + empty: "No reflections yet.", + emptyHint: "Create a reflection here. It stays on the host org store.", + noMatch: "No reflections match this filter", + create: "New reflection", + createTitle: "New reflection", + refresh: "Refresh", + required: "Add at least one lesson, plan, or captured knowledge", + cancel: "Cancel", + delete: "Delete", + confirmDelete: "Delete this reflection?", + total: "Total", + all: "All", + employee: "Employee ID", + task: "Task ID", + employeeLabel: "Employee #", + taskLabel: "Task #", + orgLevel: "Organization", + importance: "Importance", + success: "Success factors", + failure: "Failure reasons", + gaps: "Knowledge gaps", + plan: "Improvement plan", + skills: "Extracted skills", + knowledge: "Learned knowledge", + typeTask: "Task completion", + typeError: "Error learning", + typeKnowledge: "Knowledge capture", + typeImprovement: "Improvement plan", +}; + +const ZH: typeof EN = { + title: "反思引擎", + subtitle: + "在宿主内整理复盘结论、失败原因和可复用经验。数据写在 FREEOS_HOME/org,不是边车,也不是对话。", + hostHint: + "这是组织复盘记录。智能体对话仍在 Chat。提取的技能不会变成第二套技能运行时。", + search: "搜索反思…", + loading: "加载反思…", + empty: "还没有反思记录。", + emptyHint: "在此新建一条。记录会留在宿主组织库。", + noMatch: "没有符合筛选的反思", + create: "新建反思", + createTitle: "新建反思", + refresh: "刷新", + required: "至少填写一项经验、计划或沉淀知识", + cancel: "取消", + delete: "删除", + confirmDelete: "确定删除此反思记录?", + total: "全部", + all: "全部", + employee: "员工 ID", + task: "任务 ID", + employeeLabel: "员工 #", + taskLabel: "任务 #", + orgLevel: "组织", + importance: "重要性", + success: "成功因素", + failure: "失败原因", + gaps: "知识缺口", + plan: "改进计划", + skills: "提取技能", + knowledge: "沉淀知识", + typeTask: "任务完成", + typeError: "错误学习", + typeKnowledge: "知识沉淀", + typeImprovement: "改进计划", +}; + +export type ReflectionLabels = typeof EN; + +export function reflectionLabels(locale: OrgLocale): ReflectionLabels { + return locale === "zh" ? ZH : EN; +} diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index f2c18bb7..acab8891 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -40,6 +40,10 @@ function renderOrg() { path="/organization/tasks" element={
tasks
} /> + reflections} + /> experts} @@ -274,6 +278,15 @@ describe("OrganizationPage", () => { expect(screen.queryByTestId("org-browser-frame")).toBeNull(); }); + it("opens in-host reflections without a sidecar iframe", async () => { + renderOrg(); + fireEvent.click(await screen.findByTestId("org-open-reflections")); + expect( + await screen.findByTestId("org-reflections-page"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); + }); + it("imports into FreeOS and opens Experts so colleagues are selectable", async () => { vi.mocked(orgModuleApi.assemble).mockResolvedValue({ sidecar_reachable: false, diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index c0df4ed7..79e5e1f3 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -4,6 +4,7 @@ import { Button, Drawer, Input, Space, Switch, Tag } from "antd"; import { ArrowDownUp, BookOpen, + Brain, Building2, Download, ListTodo, @@ -514,6 +515,22 @@ export default function OrganizationPage() { {t("organization.openTasks")} +
+ +

+ {t("organization.reflectionsTitle")} +

+

+ {t("organization.reflectionsBody")} +

+ +
{firstRun && ( diff --git a/dashboard/src/pages/Organization/orgPathTabs.tsx b/dashboard/src/pages/Organization/orgPathTabs.tsx index 2fbe5a09..96941a56 100644 --- a/dashboard/src/pages/Organization/orgPathTabs.tsx +++ b/dashboard/src/pages/Organization/orgPathTabs.tsx @@ -1,5 +1,6 @@ import { BookOpen, + Brain, Building2, ListTodo, Megaphone, @@ -21,7 +22,8 @@ export function useOrgPathTabs( | "skills" | "governance" | "knowledge" - | "tasks", + | "tasks" + | "reflections", ): PathTabsConfig { const { t } = useTranslation(); const navigate = useNavigate(); @@ -68,6 +70,11 @@ export function useOrgPathTabs( label: t("organization.navTasks"), icon: , }, + { + value: "reflections", + label: t("organization.navReflections"), + icon: , + }, ], onChange: (value) => { if (value === "announcements") { @@ -98,6 +105,10 @@ export function useOrgPathTabs( navigate("/organization/tasks"); return; } + if (value === "reflections") { + navigate("/organization/reflections"); + return; + } navigate("/organization"); }, }; diff --git a/dashboard/src/pages/Organization/reflections/index.tsx b/dashboard/src/pages/Organization/reflections/index.tsx new file mode 100644 index 00000000..e9777ca1 --- /dev/null +++ b/dashboard/src/pages/Organization/reflections/index.tsx @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { request } from "../../../api/request"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; +import PageShell from "../../../layouts/PageShell"; +import { + ReflectionsPage, + createOrgApiClient, + type OrgSession, +} from "../../../org-ui"; +import { useOrgPathTabs } from "../orgPathTabs"; + +export default function OrganizationReflectionsPage() { + const { t, i18n } = useTranslation(); + const user = useCurrentUser(); + const pathTabs = useOrgPathTabs("reflections"); + const client = useMemo( + () => + createOrgApiClient({ + fetchJson: (path, init) => request(path, init), + }), + [], + ); + const locale = i18n.language?.toLowerCase().startsWith("zh") ? "zh" : "en"; + const session: OrgSession = { + userId: user?.id ?? 0, + displayName: user?.display_name || user?.username || "", + role: user?.role || "user", + isAdmin: user?.role === "admin", + }; + + return ( + + + + ); +} diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index a3442c50..5bc6bbcf 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -74,6 +74,7 @@ describe("pathPermissionKeys", () => { expect(pathPermissionKeys("/organization/governance")).toBeNull(); expect(pathPermissionKeys("/organization/knowledge")).toBeNull(); expect(pathPermissionKeys("/organization/tasks")).toBeNull(); + expect(pathPermissionKeys("/organization/reflections")).toBeNull(); expect(pathPermissionKeys("/projects")).toBeNull(); expect(pathPermissionKeys("/tasks")).toBeNull(); expect(pathPermissionKeys("/token-usage")).toBeNull(); @@ -164,6 +165,9 @@ describe("unknown dashboard paths", () => { expect(resolveSelectedKey("/organization/knowledge")).toBe("organization"); expect(resolveSelectedKey("/organization/tasks")).toBe("organization"); expect(resolveSelectedKey("/organization/tasks/3")).toBe("organization"); + expect(resolveSelectedKey("/organization/reflections")).toBe( + "organization", + ); }); it("are caught by the not-found route", () => { diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 2412b78b..f7828d2c 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -41,6 +41,9 @@ const OrganizationTasksPage = lazy(() => import("../pages/Organization/tasks")); const OrganizationTaskDetailPage = lazy( () => import("../pages/Organization/tasks/detail"), ); +const OrganizationReflectionsPage = lazy( + () => import("../pages/Organization/reflections"), +); const ProjectsPage = lazy(() => import("../pages/Projects")); // Misc @@ -101,6 +104,7 @@ export const pathToKey: Record = { "/organization/governance": "organization", "/organization/knowledge": "organization", "/organization/tasks": "organization", + "/organization/reflections": "organization", "/projects": "projects", "/tasks": "projects", "/connectors": "personalization", @@ -267,6 +271,10 @@ export const routeConfigs: RouteConfig[] = [ path: "/organization/tasks/:id", element: , }, + { + path: "/organization/reflections", + element: , + }, { path: "/projects", element: }, { path: "/tasks", element: }, { diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index 2ec9591a..1003612f 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -290,7 +290,7 @@ The Phase A work sits on the existing FreeOS bootstrap: | Self-growth loop | Bridge | `freeos org loop run` · `/api/org-module/loop/run` | | Colleague agents | Host agents table | `org-` · `{home}/org-agents/` · tenant `routing.json` | | Org APIs | Host BFF + optional sidecar | `/api/org-module/*` in-host (announcements + org chart CRUD are in-host); sidecar `/api/org`, `/api/employees`, … until each remaining CRUD slice moves (see [org-merge-plan.md](org-merge-plan.md)) | -| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite; skills list host `org-skills` + Agent skill packages; governance reads host pauses/audit). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | +| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite; skills list host `org-skills` + Agent skill packages; governance reads host pauses/audit; knowledge bridges host KBs; tasks/reflections live under `{FREEOS_HOME}/org`). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | ### Auth and tenant mapping diff --git a/docs/org-merge-plan.md b/docs/org-merge-plan.md index e7fd7f2a..c39c007d 100644 --- a/docs/org-merge-plan.md +++ b/docs/org-merge-plan.md @@ -1,6 +1,6 @@ # 组织模块合并计划(openXYOS → FreeOS Dashboard) -**状态:** Phase 3 组织架构、员工目录、技能、治理 UI、知识与任务切片已落地(宿主 org tree / 员工 CRUD + skill_bridge list/read + 治理 pauses/audit + 宿主知识库桥接 + `{FREEOS_HOME}/org/tasks.sqlite` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `org-ui` OrgChartPage / EmployeesPage / SkillsPage / GovernancePage / KnowledgePage / TasksPage + export-standalone 模块列表)。其余 Open-12 仍待切片。 +**状态:** Phase 3 组织架构、员工目录、技能、治理 UI、知识、任务与反思切片已落地(宿主 org tree / 员工 CRUD + skill_bridge list/read + 治理 pauses/audit + 宿主知识库桥接 + `{FREEOS_HOME}/org/tasks.sqlite` + `{FREEOS_HOME}/org/reflections.sqlite` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` + `org-ui` OrgChartPage / EmployeesPage / SkillsPage / GovernancePage / KnowledgePage / TasksPage / ReflectionsPage + export-standalone 模块列表)。其余 Open-12 仍待切片。 **日期:** 2026-09-19 **依据:** Phase 0 迁移图、[architecture-integration.md](architecture-integration.md)、[asset-loop.md](asset-loop.md)、[ADR 001](adr/001-single-process-model.md)、#55 宿主内 Organization @@ -25,7 +25,7 @@ Phase 1 冻结了合同。Phase 2(通知公告)与 Phase 3 的组织架构 | **0** 宿主内地基 | `org_os` 控制面、`/api/org-module/*` BFF、Dashboard 原生工作台、sidecar 改为可选 | **已完成**(#55) | | **1** 合同冻结 | ADR + 本计划:单源双交付、包布局、API 归属、IdentityBridge、Phase 2 验收 | **本文** | | **2** 垂直切片 | **通知公告 Announcements** 按合同落地(Dashboard 路由 + 可导出同一页面) | **已完成** | -| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:架构 / 员工 / 技能 / 治理 / 知识 / 任务已迁;智能体、反思、设置、工作台扩展未开始 | +| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:架构 / 员工 / 技能 / 治理 / 知识 / 任务 / 反思已迁;智能体、设置、工作台扩展未开始 | | **4** 身份与 CRUD | 已迁页面走 IdentityBridge;业务 CRUD 按切片迁入宿主;未迁路由仍可代理到可选 sidecar | 与 2–3 交叉推进 | | **5** 导出与安装器 | `freeos org export-standalone` 产出独立站;默认安装器保持零 Node | 未开始 | @@ -63,7 +63,7 @@ Catalog 十二键(`catalog.py` ↔ `open-module-catalog.ts`): | agents | OpenApp `/agents`(Agent Studio) | 源码有 `routes/agent-studio.ts`,**`server.ts` 当前未 `app.use`** | Phase 3 前必须先核实挂载;未挂载则只迁 UI、API 在宿主补齐 | | tasks | OpenApp `/tasks` | sidecar `/api/tasks` | **Phase 3 切片已落地**:`/organization/tasks` + `/api/org-module/tasks*`,`{FREEOS_HOME}/org/tasks.sqlite`。不是 Octop cron,也不是项目/Chat。未迁:附件、边车任务库 | | knowledge | OpenApp `/knowledge` | sidecar `/api/knowledge` | **Phase 3 切片已落地**:`/organization/knowledge` 列出宿主 `/api/knowledge-bases` 同一套行;不克隆 sidecar notes/files DB | -| reflections | OpenApp `/reflections` | sidecar `/api/reflections` | Phase 3 | +| reflections | OpenApp `/reflections` | sidecar `/api/reflections` | **Phase 3 切片已落地**:`/organization/reflections` + `/api/org-module/reflections*`,`{FREEOS_HOME}/org/reflections.sqlite`。不是 Chat,也不是第二套技能运行时。未迁:边车技能统计、部署铁律样例 | | governance UI | OpenApp `/governance` | sidecar `/api/governance` + 宿主 `org_os/governance` | **Phase 3 切片已落地**:`/organization/governance` 展示待审批 / 审计 / 裁决;PEP/PDP 与 durable pause **已在宿主,勿重写引擎**。未迁:边车权限矩阵、通信规则、流程模板 | | settings | OpenApp `/settings` | sidecar `/api/settings`、`/api/module-settings` | Phase 3:租户/模块开关;宿主已有 `PATCH /api/org-module` 与 `/modules` | @@ -120,6 +120,7 @@ uv run freeos org export-standalone --out dist/openxyos-web | `governance/check\|resolve\|audit` · `pauses` | 宿主 PEP/PDP | **已挂 UI**。`GET /pauses` 补列出待审批;resolve 仍管理员/`plugins` 门控 | | `knowledge` list/read/create/notes | 宿主知识库桥接 | **已挂 UI**。包装 `KnowledgeService`;写操作 `knowledge_bases` 门控。不克隆 sidecar notes DB | | `tasks` list/stats/detail/CRUD/transition/subtasks/comments | 宿主组织待办 | **已挂 UI**。`{FREEOS_HOME}/org/tasks.sqlite`。不是 cron / 项目任务 / Chat | +| `reflections` list/stats/detail/create/delete | 宿主组织复盘 | **已挂 UI**。`{FREEOS_HOME}/org/reflections.sqlite`。不是 Chat / 第二套技能运行时 | | `sidecar/*` · `source/download` | 可选兼容 | 留在 Advanced;默认路径不依赖 | ### 业务 CRUD(当前 sidecar Express → 按切片迁入宿主) @@ -130,7 +131,8 @@ uv run freeos org export-standalone --out dist/openxyos-web | 组织树 | sidecar `/api/org` | **已迁。** 宿主 `/api/org-module/org`(tree / departments / employees 目录 CRUD),`{FREEOS_HOME}/org/org_chart.sqlite`。未迁:versions、reporting-lines、skills、import、头像上传 | | 员工目录 | sidecar `/api/employees` | **已迁目录 CRUD。** 宿主 `/api/org-module/org/employees*`,与 org chart **同一** `{FREEOS_HOME}/org/org_chart.sqlite`。lifecycle 仍是 `GET /api/org-module/employees`(同事 registry)。未迁:人才市场、备选入职、资产离职清算、绩效、汇报线、技能绑定、头像上传 | | 任务 | sidecar `/api/tasks` | **已迁。** 宿主 `/api/org-module/tasks*`,`{FREEOS_HOME}/org/tasks.sqlite`。不桥接 cron,不迁 Chat | -| 反思 / 设置 | sidecar `/api/reflections` 等 | Phase 3 其余页:同一模式,迁完才摘代理 | +| 反思 | sidecar `/api/reflections` | **已迁。** 宿主 `/api/org-module/reflections*`,`{FREEOS_HOME}/org/reflections.sqlite`。不迁 Chat,不迁边车技能统计 | +| 设置 | sidecar `/api/settings` 等 | Phase 3 其余页:同一模式,迁完才摘代理 | | 知识 | sidecar `/api/knowledge` | **已迁为宿主桥接。** `GET /api/org-module/knowledge*` 读 Octop `knowledge_bases` / `knowledge_documents`;笔记写成宿主 markdown 文档。sidecar notes DB 仍是可选兼容 | | 未迁资源 | sidecar 或 `/api/org-module/sidecar/api/` 代理 | 可选 sidecar 仍可用;默认安装不启动它 | | Chat | sidecar `/api/chats` | **永久不迁** | @@ -385,6 +387,39 @@ openXYOS sidecar `/api/governance`(权限矩阵、通信规则、流程模板 --- +## Phase 3 进度:反思(本切片) + +按同一缝落地,**只迁组织复盘**。不把 openXYOS Chat 或 sidecar `employee_skill_stats` 搬进宿主。 + +### 数据模型(单一 SoT) + +| 表面 | 存储 | API | 含义 | +|---|---|---|---| +| 组织反思 | `{FREEOS_HOME}/org/reflections.sqlite` | `/api/org-module/reflections*` | 任务完成 / 错误学习 / 知识沉淀 / 改进计划 | +| 目录员工 / 组织任务 | `org_chart.sqlite` / `tasks.sqlite` | 可选 `employee_id` / `task_id` | 同一租户的目录员工与组织待办,不是边车表 | +| sidecar 反思 | openXYOS SQL.js | 可选 `/api/reflections` | **不迁、不克隆。** 默认路径不读它 | + +可选 `employee_id` 指向同一租户的目录员工;可选 `task_id` 指向组织任务。提取技能字段只是文本,**不会**写入技能运行时。 + +### 已落地 + +1. 壳无关组件:`dashboard/src/org-ui/pages/reflections`(`ReflectionsPage`),数据经 `createOrgApiClient().reflections`。 +2. Dashboard 子路由:`/organization/reflections`(工作台 path tabs + 入口卡)。 +3. 宿主 API:list / stats / detail / create / delete;嵌入模式打宿主 JWT,不打 `:3780`。 +4. 导出骨架:`freeos org export-standalone` 模块列表与 `App.tsx` 同时导入 `ReflectionsPage`。 + +### 本切片明确推迟 + +| 推迟项 | 原因 | +|---|---| +| Chat / 从反思页发起会话 | 对话运行时留在 FreeOS/Octop | +| 边车 `employee_skill_stats` / 技能使用统计 | 组织技能已有独立切片;本页不另造技能库 | +| 边车「系统部署铁律」样例文案 | openXYOS 自身部署备忘,不是组织复盘数据 | +| 其余 Open-12(智能体、设置、工作台扩展) | 后续切片 | +| `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | + +--- + ## 明确非目标 | 非目标 | 说明 | diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md index 50b216b5..636d8864 100644 --- a/scripts/org-export/README.md +++ b/scripts/org-export/README.md @@ -4,7 +4,7 @@ **imports the same org-ui pages** Dashboard mounts under `/organization/...`. Phase 3 includes **Announcements**, **Org chart**, **Employees**, **Skills**, -**Governance**, **Knowledge**, and **Tasks**: +**Governance**, **Knowledge**, **Tasks**, and **Reflections**: | Delivery | Route | Component | |---|---|---| @@ -24,6 +24,8 @@ Phase 3 includes **Announcements**, **Org chart**, **Employees**, **Skills**, | Dashboard | `/organization/tasks` | `dashboard/src/org-ui` → `TasksPage` | | Dashboard | `/organization/tasks/:id` | `dashboard/src/org-ui` → `TaskDetailPage` | | Standalone (this export) | `/tasks` | same import | +| Dashboard | `/organization/reflections` | `dashboard/src/org-ui` → `ReflectionsPage` | +| Standalone (this export) | `/reflections` | same import | Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org chart. Organization skills live under `{FREEOS_HOME}/org-skills/` (skill_bridge). @@ -32,6 +34,8 @@ Organization Knowledge lists host FreeOS knowledge bases (same rows as `/knowledge-bases`); it does not clone the sidecar notes/files DB. Organization Tasks live in `{FREEOS_HOME}/org/tasks.sqlite` — not Octop cron and not agent/project chat. +Organization Reflections live in `{FREEOS_HOME}/org/reflections.sqlite` — +lessons learned, not Chat and not a second skill runtime. Do not copy the pages into a second tree. ## Phase 5 TODO diff --git a/src/octop/api/openapi_meta.py b/src/octop/api/openapi_meta.py index eb3ca66e..68eaabf2 100644 --- a/src/octop/api/openapi_meta.py +++ b/src/octop/api/openapi_meta.py @@ -162,7 +162,7 @@ }, { "name": "org-module", - "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements, org chart, employees, skills, governance, knowledge, and tasks, sidecar, and growth loop.", + "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements, org chart, employees, skills, governance, knowledge, tasks, and reflections, sidecar, and growth loop.", }, { "name": "browser", diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index 9a3cb1a1..13279e25 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -14,6 +14,7 @@ from octop.api.routers.org_announcements import router as announcements_router from octop.api.routers.org_chart import router as org_chart_router from octop.api.routers.org_knowledge import router as knowledge_router +from octop.api.routers.org_reflections import router as reflections_router from octop.api.routers.org_tasks import router as tasks_router from octop.infra.server import OctopServer from octop.infra.utils.locale import resolve_request_locale @@ -42,6 +43,7 @@ router.include_router(org_chart_router) router.include_router(knowledge_router) router.include_router(tasks_router) +router.include_router(reflections_router) class OrgModulePatch(BaseModel): diff --git a/src/octop/api/routers/org_reflections.py b/src/octop/api/routers/org_reflections.py new file mode 100644 index 00000000..b192b8ec --- /dev/null +++ b/src/octop/api/routers/org_reflections.py @@ -0,0 +1,188 @@ +"""In-host organization reflections CRUD — openXYOS-shaped envelope, FreeOS JWT.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from octop.api.deps import current_user, get_server +from octop.i18n import tr +from octop.infra.server import OctopServer +from octop.infra.utils.locale import resolve_request_locale +from octop.modules.org_os.org_chart.store import OrgChartStore +from octop.modules.org_os.reflections.store import ( + REFLECTION_TYPES, + ReflectionStore, + has_content, +) +from octop.modules.org_os.service import org_module_from_paths +from octop.modules.org_os.tasks.store import TaskStore + +router = APIRouter() + + +class ReflectionWriteBody(BaseModel): + employee_id: int | None = Field(default=None) + task_id: int | None = Field(default=None) + reflection_type: str | None = None + success_factors: str | None = None + failure_reasons: str | None = None + knowledge_gaps: str | None = None + improvement_plans: str | None = None + extracted_skills: str | None = None + learned_knowledge: str | None = None + importance_score: int | None = Field(default=None) + + +def _tenant_id(server: OctopServer) -> str: + return org_module_from_paths(server.paths).tenant_id() or "default" + + +def _store(server: OctopServer) -> ReflectionStore: + return ReflectionStore(server.paths.root) + + +def _locale(request: Request) -> str: + return resolve_request_locale(request) + + +def _fail(request: Request, key: str, status: int) -> JSONResponse: + return JSONResponse( + status_code=status, + content={"success": False, "error": tr(key, _locale(request))}, + ) + + +def _ok(data: Any = None) -> dict[str, Any]: + payload: dict[str, Any] = {"success": True} + if data is not None: + payload["data"] = data + return payload + + +def _resolve_employee(server: OctopServer, tenant_id: str, employee_id: int | None) -> int | None: + if employee_id is None or int(employee_id) <= 0: + return 0 + chart = OrgChartStore(server.paths.root) + employee = chart.get_employee(int(employee_id), tenant_id=tenant_id) + if employee is None or employee.get("status") != "active": + return None + return int(employee["id"]) + + +def _resolve_task( + server: OctopServer, tenant_id: str, task_id: int | None +) -> tuple[bool, int | None]: + if task_id is None: + return True, None + task = TaskStore(server.paths.root).get(int(task_id), tenant_id=tenant_id) + if task is None: + return False, None + return True, int(task["id"]) + + +@router.get("/reflections", summary="List organization reflections") +async def list_reflections( + employee_id: int | None = Query(default=None), + reflection_type: str = Query(default="", alias="type"), + search: str = Query(default=""), + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + store = _store(server) + rows = await asyncio.to_thread( + store.list, + tenant_id=_tenant_id(server), + employee_id=employee_id, + reflection_type=reflection_type, + search=search, + ) + return _ok(rows) + + +@router.get("/reflections/stats", summary="Organization reflection counts by type") +async def reflection_stats( + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + store = _store(server) + data = await asyncio.to_thread(store.stats, tenant_id=_tenant_id(server)) + return _ok(data) + + +@router.get( + "/reflections/{reflection_id}", + summary="Organization reflection detail", + response_model=None, +) +async def get_reflection( + reflection_id: int, + request: Request, + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + store = _store(server) + row = await asyncio.to_thread(store.get, reflection_id, tenant_id=_tenant_id(server)) + if row is None: + return _fail(request, "org.reflections.not_found", 404) + return _ok(row) + + +@router.post("/reflections", summary="Create an organization reflection", response_model=None) +async def create_reflection( + body: ReflectionWriteBody, + request: Request, + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + payload = body.model_dump() + if not has_content(payload): + return _fail(request, "org.reflections.content_required", 400) + kind = (body.reflection_type or "task_completion").strip().lower() + if kind not in REFLECTION_TYPES: + return _fail(request, "org.reflections.invalid_type", 400) + tenant = _tenant_id(server) + employee_id = _resolve_employee(server, tenant, body.employee_id) + if employee_id is None: + return _fail(request, "org.reflections.employee_invalid", 400) + task_ok, task_id = _resolve_task(server, tenant, body.task_id) + if not task_ok: + return _fail(request, "org.reflections.task_invalid", 400) + store = _store(server) + row = await asyncio.to_thread( + store.create, + tenant_id=tenant, + employee_id=employee_id, + task_id=task_id, + reflection_type=kind, + success_factors=body.success_factors, + failure_reasons=body.failure_reasons, + knowledge_gaps=body.knowledge_gaps, + improvement_plans=body.improvement_plans, + extracted_skills=body.extracted_skills, + learned_knowledge=body.learned_knowledge, + importance_score=body.importance_score if body.importance_score is not None else 50, + ) + return _ok(row) + + +@router.delete( + "/reflections/{reflection_id}", + summary="Delete an organization reflection", + response_model=None, +) +async def delete_reflection( + reflection_id: int, + request: Request, + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + store = _store(server) + deleted = await asyncio.to_thread(store.delete, reflection_id, tenant_id=_tenant_id(server)) + if not deleted: + return _fail(request, "org.reflections.not_found", 404) + return _ok() diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index 1b7a9830..51a429f4 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -385,8 +385,9 @@ def export_standalone(out_dir: Path) -> None: """Scaffold a standalone Organization web from shared org-ui (Phase 3). Writes the shared module list and an App.tsx that imports AnnouncementPage, - OrgChartPage, EmployeesPage, SkillsPage, and GovernancePage. Full Vite + - Node packaging is Phase 5 (TODO in the generated README). + OrgChartPage, EmployeesPage, SkillsPage, GovernancePage, KnowledgePage, + TasksPage, and ReflectionsPage. Full Vite + Node packaging is Phase 5 + (TODO in the generated README). """ from octop.modules.org_os.export_standalone import write_standalone_scaffold diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index 0c9f9ba9..44cd027d 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -771,6 +771,14 @@ "assignee_invalid": "The assignee does not exist or is not an active directory employee", "unavailable": "Task service is temporarily unavailable. Please try again" }, + "reflections": { + "not_found": "Reflection not found", + "content_required": "Add at least one lesson, plan, or captured knowledge", + "invalid_type": "Invalid reflection type", + "employee_invalid": "The employee does not exist or is not an active directory employee", + "task_invalid": "The linked organization task does not exist", + "unavailable": "Reflection service is temporarily unavailable. Please try again" + }, "chart": { "forbidden": "Permission denied", "name_required": "Department or employee name is required", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 97c8d139..1a9e9ef7 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -771,6 +771,14 @@ "assignee_invalid": "受派员工不存在或未在职", "unavailable": "任务服务暂时不可用,请稍后重试" }, + "reflections": { + "not_found": "反思记录不存在", + "content_required": "至少填写一项经验、计划或沉淀知识", + "invalid_type": "无效的反思类型", + "employee_invalid": "员工不存在或未在职", + "task_invalid": "关联的组织任务不存在", + "unavailable": "反思服务暂时不可用,请稍后重试" + }, "chart": { "forbidden": "无权限", "name_required": "部门或员工名称不能为空", diff --git a/src/octop/modules/org_os/contract.py b/src/octop/modules/org_os/contract.py index 746b86e9..d567162c 100644 --- a/src/octop/modules/org_os/contract.py +++ b/src/octop/modules/org_os/contract.py @@ -13,6 +13,7 @@ "governance", "knowledge", "tasks", + "reflections", ) diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py index 31442865..6a5c7246 100644 --- a/src/octop/modules/org_os/export_standalone.py +++ b/src/octop/modules/org_os/export_standalone.py @@ -3,7 +3,8 @@ Full Vite + Node server packaging is Phase 5. This writes a snapshot that lists the shared org-ui modules and points at the same AnnouncementPage, OrgChartPage, EmployeesPage, SkillsPage, GovernancePage, KnowledgePage, -and TasksPage sources Dashboard mounts under ``/organization/...``. +TasksPage, and ReflectionsPage sources Dashboard mounts under +``/organization/...``. """ from __future__ import annotations @@ -31,6 +32,7 @@ | governance | `/organization/governance` | `/governance` | `dashboard/src/org-ui` → `GovernancePage` | | knowledge | `/organization/knowledge` | `/knowledge` | `dashboard/src/org-ui` → `KnowledgePage` | | tasks | `/organization/tasks` | `/tasks` | `dashboard/src/org-ui` → `TasksPage` / `TaskDetailPage` | +| reflections | `/organization/reflections` | `/reflections` | `dashboard/src/org-ui` → `ReflectionsPage` | Do **not** copy those pages into a second tree. Edit `dashboard/src/org-ui`. @@ -43,6 +45,8 @@ from. It does not clone the openXYOS sidecar notes/files DB. Organization Tasks live in `{FREEOS_HOME}/org/tasks.sqlite`. They are **not** Octop cron jobs and **not** agent/project chat. +Organization Reflections live in `{FREEOS_HOME}/org/reflections.sqlite`. +They are lessons learned, not Chat and not a second skill runtime. ## Phase 3 vs Phase 5 @@ -51,7 +55,7 @@ - Shared module list (`src/modules.json`) - Thin `src/App.tsx` that imports `AnnouncementPage`, `OrgChartPage`, `EmployeesPage`, `SkillsPage`, `GovernancePage`, `KnowledgePage`, - and `TasksPage` + `TasksPage`, and `ReflectionsPage` - Documents IdentityBridge: embedded mode uses FreeOS JWT; standalone uses local JWT Phase 5 (TODO — not implemented here): @@ -78,6 +82,7 @@ GovernancePage, KnowledgePage, OrgChartPage, + ReflectionsPage, SHARED_ORG_UI_MODULES, SkillsPage, TaskDetailPage, @@ -89,6 +94,7 @@ OrgEmployeesClient, OrgGovernanceClient, OrgKnowledgeClient, + OrgReflectionsClient, OrgSession, OrgSkillsClient, OrgTasksClient, @@ -99,7 +105,7 @@ * Page components are the same ones Dashboard mounts at * /organization/announcements, /organization/org, /organization/employees, * /organization/skills, /organization/governance, /organization/knowledge, - * and /organization/tasks. + * /organization/tasks, and /organization/reflections. */ const session: OrgSession = { userId: 0, @@ -116,6 +122,7 @@ governanceClient: OrgGovernanceClient; knowledgeClient: OrgKnowledgeClient; tasksClient: OrgTasksClient; + reflectionsClient: OrgReflectionsClient; locale?: "zh" | "en"; }) { return ( @@ -176,6 +183,12 @@ taskId={0} modules={SHARED_ORG_UI_MODULES} /> + ); } @@ -188,7 +201,7 @@ "description": ( "Standalone Organization web export skeleton. " "Pages come from dashboard/src/org-ui " - "(Phase 3: Announcements + Org chart + Employees + Skills + Governance + Knowledge + Tasks). " + "(Phase 3: Announcements + Org chart + Employees + Skills + Governance + Knowledge + Tasks + Reflections). " "Full Node packaging is Phase 5." ), "type": "module", @@ -263,6 +276,13 @@ def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: "api": "/api/org-module/tasks", "store": "{FREEOS_HOME}/org/tasks.sqlite", }, + "reflections": { + "component": "ReflectionsPage", + "embedded_route": "/organization/reflections", + "standalone_route": "/reflections", + "api": "/api/org-module/reflections", + "store": "{FREEOS_HOME}/org/reflections.sqlite", + }, }, "todo": "Phase 5: full Vite + server packaging; do not fork org-ui pages", } diff --git a/src/octop/modules/org_os/reflections/__init__.py b/src/octop/modules/org_os/reflections/__init__.py new file mode 100644 index 00000000..7a92a9a7 --- /dev/null +++ b/src/octop/modules/org_os/reflections/__init__.py @@ -0,0 +1,13 @@ +"""In-host organization reflections (Phase 3 org-ui slice).""" + +from octop.modules.org_os.reflections.store import ( + REFLECTION_TYPES, + ReflectionStore, + has_content, +) + +__all__ = [ + "REFLECTION_TYPES", + "ReflectionStore", + "has_content", +] diff --git a/src/octop/modules/org_os/reflections/store.py b/src/octop/modules/org_os/reflections/store.py new file mode 100644 index 00000000..7a788c8e --- /dev/null +++ b/src/octop/modules/org_os/reflections/store.py @@ -0,0 +1,223 @@ +"""SQLite organization reflections under ``{FREEOS_HOME}/org/``. + +This is **not** sidecar SQL.js and **not** a second skill/knowledge runtime. +Reflections are organizational lessons (success, failure, plans) so the +Reflections org-ui page can run in-process without a Node sidecar. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +REFLECTION_TYPES = ( + "task_completion", + "error_learning", + "knowledge_capture", + "improvement", +) + +_TEXT_FIELDS = ( + "success_factors", + "failure_reasons", + "knowledge_gaps", + "improvement_plans", + "extracted_skills", + "learned_knowledge", +) + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS reflections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id TEXT NOT NULL, + employee_id INTEGER NOT NULL DEFAULT 0, + task_id INTEGER, + reflection_type TEXT NOT NULL DEFAULT 'task_completion', + success_factors TEXT, + failure_reasons TEXT, + knowledge_gaps TEXT, + improvement_plans TEXT, + extracted_skills TEXT, + learned_knowledge TEXT, + importance_score INTEGER NOT NULL DEFAULT 50, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_reflections_tenant + ON reflections (tenant_id, reflection_type, importance_score, created_at, id); +""" + + +def utc_now() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def normalize_type(value: str | None) -> str: + raw = (value or "task_completion").strip().lower() + return raw if raw in REFLECTION_TYPES else "task_completion" + + +def normalize_importance(value: int | None) -> int: + try: + score = int(value if value is not None else 50) + except (TypeError, ValueError): + return 50 + return max(0, min(100, score)) + + +def has_content(data: dict[str, Any]) -> bool: + return any(str(data.get(field) or "").strip() for field in _TEXT_FIELDS) + + +class ReflectionStore: + """Tenant-scoped organization reflections.""" + + def __init__(self, home: Path) -> None: + self.home = home + self.path = home / "org" / "reflections.sqlite" + self.path.parent.mkdir(parents=True, exist_ok=True) + self._init() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode = WAL") + return conn + + def _init(self) -> None: + with self._connect() as conn: + conn.executescript(_SCHEMA) + + def create( + self, + *, + tenant_id: str, + employee_id: int = 0, + task_id: int | None = None, + reflection_type: str = "task_completion", + success_factors: str | None = None, + failure_reasons: str | None = None, + knowledge_gaps: str | None = None, + improvement_plans: str | None = None, + extracted_skills: str | None = None, + learned_knowledge: str | None = None, + importance_score: int = 50, + ) -> dict[str, Any]: + now = utc_now() + with self._connect() as conn: + cur = conn.execute( + """ + INSERT INTO reflections ( + tenant_id, employee_id, task_id, reflection_type, + success_factors, failure_reasons, knowledge_gaps, + improvement_plans, extracted_skills, learned_knowledge, + importance_score, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + tenant_id, + int(employee_id or 0), + task_id, + normalize_type(reflection_type), + _blank_to_none(success_factors), + _blank_to_none(failure_reasons), + _blank_to_none(knowledge_gaps), + _blank_to_none(improvement_plans), + _blank_to_none(extracted_skills), + _blank_to_none(learned_knowledge), + normalize_importance(importance_score), + now, + ), + ) + row_id = int(cur.lastrowid or 0) + row = self.get(row_id, tenant_id=tenant_id) + assert row is not None + return row + + def get(self, reflection_id: int, *, tenant_id: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM reflections WHERE id = ? AND tenant_id = ?", + (reflection_id, tenant_id), + ).fetchone() + return self._row(dict(row)) if row is not None else None + + def list( + self, + *, + tenant_id: str, + employee_id: int | None = None, + reflection_type: str = "", + search: str = "", + ) -> list[dict[str, Any]]: + where = ["tenant_id = ?"] + params: list[Any] = [tenant_id] + if employee_id is not None: + where.append("employee_id = ?") + params.append(employee_id) + if reflection_type and reflection_type != "all": + where.append("reflection_type = ?") + params.append(normalize_type(reflection_type)) + if search.strip(): + like = f"%{search.strip()}%" + where.append("(" + " OR ".join(f"{field} LIKE ?" for field in _TEXT_FIELDS) + ")") + params.extend([like] * len(_TEXT_FIELDS)) + clause = " AND ".join(where) + with self._connect() as conn: + rows = conn.execute( + f""" + SELECT * FROM reflections + WHERE {clause} + ORDER BY importance_score DESC, created_at DESC, id DESC + """, + params, + ).fetchall() + return [self._row(dict(row)) for row in rows] + + def stats(self, *, tenant_id: str) -> dict[str, int]: + counts: dict[str, int] = dict.fromkeys(REFLECTION_TYPES, 0) + counts["total"] = 0 + with self._connect() as conn: + rows = conn.execute( + """ + SELECT reflection_type, COUNT(*) AS count + FROM reflections WHERE tenant_id = ? + GROUP BY reflection_type + """, + (tenant_id,), + ).fetchall() + for row in rows: + kind = str(row["reflection_type"]) + n = int(row["count"]) + if kind in counts: + counts[kind] = n + counts["total"] += n + return counts + + def delete(self, reflection_id: int, *, tenant_id: str) -> bool: + existing = self.get(reflection_id, tenant_id=tenant_id) + if existing is None: + return False + with self._connect() as conn: + conn.execute( + "DELETE FROM reflections WHERE id = ? AND tenant_id = ?", + (reflection_id, tenant_id), + ) + return True + + def _row(self, row: dict[str, Any]) -> dict[str, Any]: + out = dict(row) + out["id"] = int(row["id"]) + out["employee_id"] = int(row.get("employee_id") or 0) + raw_task = row.get("task_id") + out["task_id"] = ( + int(raw_task) if isinstance(raw_task, int | str) and raw_task != "" else None + ) + out["importance_score"] = int(row.get("importance_score") or 0) + return out + + +def _blank_to_none(value: str | None) -> str | None: + text = (value or "").strip() + return text or None diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py index d2cfdd84..6dd2eb6a 100644 --- a/tests/unit/cli/test_org_export_standalone.py +++ b/tests/unit/cli/test_org_export_standalone.py @@ -24,6 +24,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "governance", "knowledge", "tasks", + "reflections", ] modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) assert modules["shared_org_ui_modules"] == [ @@ -34,6 +35,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "governance", "knowledge", "tasks", + "reflections", ] assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" assert modules["pages"]["announcements"]["embedded_route"] == "/organization/announcements" @@ -55,6 +57,10 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert modules["pages"]["tasks"]["embedded_route"] == "/organization/tasks" assert modules["pages"]["tasks"]["api"] == "/api/org-module/tasks" assert modules["pages"]["tasks"]["store"] == "{FREEOS_HOME}/org/tasks.sqlite" + assert modules["pages"]["reflections"]["component"] == "ReflectionsPage" + assert modules["pages"]["reflections"]["embedded_route"] == "/organization/reflections" + assert modules["pages"]["reflections"]["api"] == "/api/org-module/reflections" + assert modules["pages"]["reflections"]["store"] == "{FREEOS_HOME}/org/reflections.sqlite" app = (out / "src" / "App.tsx").read_text(encoding="utf-8") assert "AnnouncementPage" in app assert "OrgChartPage" in app @@ -65,6 +71,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "KnowledgePage" in app assert "TasksPage" in app assert "TaskDetailPage" in app + assert "ReflectionsPage" in app assert 'from "org-ui"' in app readme = (out / "README.md").read_text(encoding="utf-8") assert "Phase 5" in readme @@ -75,3 +82,4 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "GovernancePage" in readme assert "KnowledgePage" in readme assert "TasksPage" in readme + assert "ReflectionsPage" in readme diff --git a/tests/unit/test_org_reflections.py b/tests/unit/test_org_reflections.py new file mode 100644 index 00000000..383e4104 --- /dev/null +++ b/tests/unit/test_org_reflections.py @@ -0,0 +1,176 @@ +"""In-host organization reflections store and /api/org-module/reflections.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from octop.api.deps import current_user, get_server +from octop.api.routers.org_module import router +from octop.infra.users.identity import Role, User +from octop.modules.org_os.contract import ( + SHARED_ORG_UI_MODULES, + assert_shared_modules_in_catalog, +) +from octop.modules.org_os.org_chart.store import OrgChartStore +from octop.modules.org_os.reflections.store import ReflectionStore +from octop.modules.org_os.tasks.store import TaskStore + + +def _admin() -> User: + return User(id=1, username="admin", role=Role.ADMIN, display_name="Ada") + + +def _member() -> User: + return User(id=2, username="member", role=Role.USER, display_name="Mo") + + +def _client(tmp_path: Path, user: User) -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/api/org-module") + server = SimpleNamespace( + paths=SimpleNamespace(config=tmp_path / "config.json", root=tmp_path), + services=None, + ) + app.dependency_overrides[current_user] = lambda: user + app.dependency_overrides[get_server] = lambda: server + return TestClient(app) + + +def test_shared_reflections_module_is_in_catalog() -> None: + assert "reflections" in SHARED_ORG_UI_MODULES + assert_shared_modules_in_catalog() + + +def test_store_create_list_stats(tmp_path: Path) -> None: + store = ReflectionStore(tmp_path) + row = store.create( + tenant_id="default", + employee_id=0, + reflection_type="error_learning", + failure_reasons="const dead zone", + improvement_plans="re-read after edit", + importance_score=90, + ) + assert row["id"] == 1 + assert row["reflection_type"] == "error_learning" + assert row["importance_score"] == 90 + listed = store.list(tenant_id="default") + assert len(listed) == 1 + assert listed[0]["failure_reasons"] == "const dead zone" + stats = store.stats(tenant_id="default") + assert stats["total"] == 1 + assert stats["error_learning"] == 1 + assert stats["task_completion"] == 0 + + +def test_store_search_filter_and_delete(tmp_path: Path) -> None: + store = ReflectionStore(tmp_path) + store.create( + tenant_id="default", + reflection_type="knowledge_capture", + learned_knowledge="Deploy after tsc", + importance_score=80, + ) + other = store.create( + tenant_id="default", + reflection_type="improvement", + improvement_plans="Ignore me", + ) + found = store.list(tenant_id="default", search="tsc") + assert len(found) == 1 + typed = store.list(tenant_id="default", reflection_type="improvement") + assert len(typed) == 1 + assert typed[0]["id"] == other["id"] + assert store.delete(other["id"], tenant_id="default") + assert store.list(tenant_id="default", search="Ignore") == [] + assert store.get(other["id"], tenant_id="default") is None + + +def test_store_isolates_tenants(tmp_path: Path) -> None: + store = ReflectionStore(tmp_path) + store.create(tenant_id="a", success_factors="A") + store.create(tenant_id="b", success_factors="B") + assert [row["success_factors"] for row in store.list(tenant_id="a")] == ["A"] + assert store.get(1, tenant_id="b") is None + + +def test_api_member_can_create_list_and_delete(tmp_path: Path) -> None: + member = _client(tmp_path, _member()) + created = member.post( + "/api/org-module/reflections", + json={ + "reflection_type": "task_completion", + "success_factors": "Wrote tests first", + "importance_score": 70, + }, + ) + assert created.status_code == 200 + reflection_id = created.json()["data"]["id"] + listed = member.get("/api/org-module/reflections") + assert listed.status_code == 200 + assert listed.json()["data"][0]["success_factors"] == "Wrote tests first" + stats = member.get("/api/org-module/reflections/stats") + assert stats.json()["data"]["task_completion"] == 1 + detail = member.get(f"/api/org-module/reflections/{reflection_id}") + assert detail.json()["data"]["importance_score"] == 70 + assert member.delete(f"/api/org-module/reflections/{reflection_id}").status_code == 200 + assert member.get(f"/api/org-module/reflections/{reflection_id}").status_code == 404 + + +def test_api_validates_content_type_employee_and_task(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + blank = admin.post("/api/org-module/reflections", json={"reflection_type": "task_completion"}) + assert blank.status_code == 400 + bad_type = admin.post( + "/api/org-module/reflections", + json={"reflection_type": "poetry", "success_factors": "x"}, + ) + assert bad_type.status_code == 400 + ghost = admin.post( + "/api/org-module/reflections", + json={"employee_id": 999, "success_factors": "x"}, + ) + assert ghost.status_code == 400 + missing_task = admin.post( + "/api/org-module/reflections", + json={"task_id": 999, "success_factors": "x"}, + ) + assert missing_task.status_code == 400 + missing = admin.get("/api/org-module/reflections/999") + assert missing.status_code == 404 + + +def test_api_links_org_chart_employee_and_task(tmp_path: Path) -> None: + chart = OrgChartStore(tmp_path) + dept = chart.create_department(tenant_id="default", name="HQ") + employee = chart.create_employee( + tenant_id="default", + name="Ada", + department_id=int(dept["id"]), + ) + task = TaskStore(tmp_path).create( + tenant_id="default", + title="Ship Reflections slice", + created_by=1, + creator_name="Ada", + ) + admin = _client(tmp_path, _admin()) + created = admin.post( + "/api/org-module/reflections", + json={ + "employee_id": employee["id"], + "task_id": task["id"], + "reflection_type": "knowledge_capture", + "learned_knowledge": "Host org store, no sidecar", + }, + ) + assert created.status_code == 200 + data = created.json()["data"] + assert data["employee_id"] == employee["id"] + assert data["task_id"] == task["id"] + filtered = admin.get(f"/api/org-module/reflections?employee_id={employee['id']}") + assert len(filtered.json()["data"]) == 1