feat: Add dynamic category system for attribute analysis

Backend:
- Add CategoryMode enum with 4 modes (fixed_only, fixed_plus_custom, custom_only, dynamic_auto)
- Add Step 0 for LLM category analysis before attribute generation
- Implement dynamic prompts for Step 1/2 that work with N categories
- Add execute_step0(), resolve_final_categories(), assemble_dynamic_attribute_tree()
- Update SSE events to include step0_start, step0_complete, categories_resolved

Frontend:
- Add CategorySelector component with mode selection, custom category input, and category count slider
- Update types with CategoryDefinition, Step0Result, DynamicStep1Result, DynamicCausalChain
- Update api.ts with new SSE event handlers
- Update useAttribute hook with category parameters
- Integrate CategorySelector into InputPanel
- Fix mindmap to dynamically extract and display N categories (was hardcoded to 4)
- Add CSS styles for depth 5-8 to support more category levels

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-02 23:04:35 +08:00
parent eb6c0c51fa
commit 91f7f41bc1
11 changed files with 727 additions and 53 deletions

View File

@@ -5,6 +5,7 @@ import { InputPanel } from './components/InputPanel';
import { MindmapPanel } from './components/MindmapPanel';
import { useAttribute } from './hooks/useAttribute';
import type { MindmapD3Ref } from './components/MindmapD3';
import type { CategoryMode } from './types';
const { Header, Sider, Content } = Layout;
const { Title } = Typography;
@@ -27,9 +28,12 @@ function App() {
query: string,
model?: string,
temperature?: number,
chainCount?: number
chainCount?: number,
categoryMode?: CategoryMode,
customCategories?: string[],
suggestedCategoryCount?: number
) => {
await analyze(query, model, temperature, chainCount);
await analyze(query, model, temperature, chainCount, categoryMode, customCategories, suggestedCategoryCount);
};
const handleExpandAll = useCallback(() => {

View File

@@ -0,0 +1,157 @@
import { useState } from 'react';
import { Radio, Space, Input, Button, Tag, Tooltip, Slider, Typography } from 'antd';
import { InfoCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { CategoryDefinition, CategoryMode, Step0Result } from '../types';
const { Text } = Typography;
interface CategorySelectorProps {
mode: CategoryMode;
onModeChange: (mode: CategoryMode) => void;
customCategories: string[];
onCustomCategoriesChange: (cats: string[]) => void;
suggestedCount: number;
onSuggestedCountChange: (count: number) => void;
step0Result?: Step0Result;
onStep0Edit?: (cats: CategoryDefinition[]) => void;
disabled?: boolean;
}
export function CategorySelector({
mode,
onModeChange,
customCategories,
onCustomCategoriesChange,
suggestedCount,
onSuggestedCountChange,
step0Result,
onStep0Edit,
disabled
}: CategorySelectorProps) {
const [inputValue, setInputValue] = useState('');
return (
<Space direction="vertical" style={{ width: '100%' }}>
<Radio.Group
value={mode}
onChange={(e) => onModeChange(e.target.value as CategoryMode)}
disabled={disabled}
>
<Space direction="vertical">
<Radio value="fixed_only">
Fixed (使)
</Radio>
<Radio value="fixed_plus_custom">
Fixed + Custom
</Radio>
<Radio value="custom_only">
Custom Only (LLM suggests)
</Radio>
<Radio value="dynamic_auto">
Dynamic (LLM suggests, editable)
</Radio>
</Space>
</Radio.Group>
{/* 動態模式:類別數量調整 */}
{(mode === 'custom_only' || mode === 'dynamic_auto') && (
<div>
<Text>Suggested Category Count: {suggestedCount}</Text>
<Slider
min={2}
max={8}
step={1}
value={suggestedCount}
onChange={onSuggestedCountChange}
marks={{ 2: '2', 3: '3', 5: '5', 8: '8' }}
disabled={disabled}
/>
</div>
)}
{/* 固定+自定義模式 */}
{mode === 'fixed_plus_custom' && (
<div>
<Text type="secondary">Add custom categories:</Text>
<Space.Compact style={{ width: '100%', marginTop: 8 }}>
<Input
placeholder="Category name"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={() => {
if (inputValue.trim()) {
onCustomCategoriesChange([...customCategories, inputValue.trim()]);
setInputValue('');
}
}}
disabled={disabled}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
if (inputValue.trim()) {
onCustomCategoriesChange([...customCategories, inputValue.trim()]);
setInputValue('');
}
}}
disabled={disabled}
>
Add
</Button>
</Space.Compact>
{customCategories.length > 0 && (
<div style={{ marginTop: 8 }}>
{customCategories.map((cat, i) => (
<Tag
key={i}
closable
onClose={() => {
onCustomCategoriesChange(customCategories.filter((_, idx) => idx !== i));
}}
>
{cat}
</Tag>
))}
</div>
)}
</div>
)}
{/* Step 0 結果顯示 */}
{step0Result && (mode === 'custom_only' || mode === 'dynamic_auto') && (
<div style={{ marginTop: 8, padding: 12, background: 'rgba(0,0,0,0.04)', borderRadius: 4 }}>
<Text strong>LLM Suggested:</Text>
<div style={{ marginTop: 8 }}>
{step0Result.categories.map((cat, i) => (
<Tag
key={i}
color="blue"
closable={mode === 'dynamic_auto'}
onClose={mode === 'dynamic_auto' ? () => {
if (onStep0Edit) {
onStep0Edit(step0Result.categories.filter((_, idx) => idx !== i));
}
} : undefined}
>
{cat.name}
{cat.description && (
<Tooltip title={cat.description}>
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</Tooltip>
)}
</Tag>
))}
</div>
{mode === 'dynamic_auto' && (
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: 'block' }}>
You can remove tags or proceed
</Text>
)}
</div>
)}
</Space>
);
}

View File

@@ -22,8 +22,9 @@ import {
LoadingOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import type { HistoryItem, AttributeNode, StreamProgress } from '../types';
import type { HistoryItem, AttributeNode, StreamProgress, CategoryMode, DynamicCausalChain, CausalChain } from '../types';
import { getModels } from '../services/api';
import { CategorySelector } from './CategorySelector';
const { TextArea } = Input;
const { Text } = Typography;
@@ -38,7 +39,15 @@ interface InputPanelProps {
progress: StreamProgress;
history: HistoryItem[];
currentResult: AttributeNode | null;
onAnalyze: (query: string, model?: string, temperature?: number, chainCount?: number) => Promise<void>;
onAnalyze: (
query: string,
model?: string,
temperature?: number,
chainCount?: number,
categoryMode?: CategoryMode,
customCategories?: string[],
suggestedCategoryCount?: number
) => Promise<void>;
onLoadHistory: (item: HistoryItem) => void;
onExpandAll?: () => void;
onCollapseAll?: () => void;
@@ -64,6 +73,10 @@ export function InputPanel({
const [loadingModels, setLoadingModels] = useState(false);
const [temperature, setTemperature] = useState(0.7);
const [chainCount, setChainCount] = useState(5);
// Category settings
const [categoryMode, setCategoryMode] = useState<CategoryMode>('dynamic_auto' as CategoryMode);
const [customCategories, setCustomCategories] = useState<string[]>([]);
const [suggestedCategoryCount, setSuggestedCategoryCount] = useState(3);
useEffect(() => {
async function fetchModels() {
@@ -92,7 +105,15 @@ export function InputPanel({
}
try {
await onAnalyze(query.trim(), selectedModel, temperature, chainCount);
await onAnalyze(
query.trim(),
selectedModel,
temperature,
chainCount,
categoryMode,
customCategories.length > 0 ? customCategories : undefined,
suggestedCategoryCount
);
setQuery('');
} catch {
message.error('Analysis failed');
@@ -191,14 +212,27 @@ export function InputPanel({
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
};
// Helper to format chain display (supports both fixed and dynamic chains)
const formatChain = (chain: CausalChain | DynamicCausalChain): string => {
if ('chain' in chain) {
// Dynamic chain
return Object.values(chain.chain).join(' → ');
} else {
// Fixed chain
return `${chain.material}${chain.function}${chain.usage}${chain.user}`;
}
};
const renderProgressIndicator = () => {
if (progress.step === 'idle' || progress.step === 'done') return null;
const percent = progress.step === 'step1'
? 10
: progress.step === 'chains'
? 10 + (progress.currentChainIndex / progress.totalChains) * 90
: 100;
const percent = progress.step === 'step0'
? 5
: progress.step === 'step1'
? 10
: progress.step === 'chains'
? 10 + (progress.currentChainIndex / progress.totalChains) * 90
: 100;
return (
<div style={{ marginBottom: 16, padding: 12, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
@@ -213,6 +247,20 @@ export function InputPanel({
</Space>
<Progress percent={Math.round(percent)} size="small" status={progress.step === 'error' ? 'exception' : 'active'} />
{/* Show categories used */}
{progress.categoriesUsed && progress.categoriesUsed.length > 0 && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>Categories:</Text>
<div style={{ marginTop: 4 }}>
{progress.categoriesUsed.map((cat, i) => (
<Tag key={i} color={cat.is_fixed ? 'default' : 'blue'}>
{cat.name}
</Tag>
))}
</div>
</div>
)}
{progress.completedChains.length > 0 && (
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>Completed chains:</Text>
@@ -220,7 +268,7 @@ export function InputPanel({
{progress.completedChains.map((chain, i) => (
<div key={i} style={{ fontSize: 11, padding: '2px 0' }}>
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 4 }} />
{chain.material} {chain.function} {chain.usage} {chain.user}
{formatChain(chain)}
</div>
))}
</div>
@@ -232,6 +280,22 @@ export function InputPanel({
};
const collapseItems = [
{
key: 'categories',
label: 'Category Settings',
children: (
<CategorySelector
mode={categoryMode}
onModeChange={setCategoryMode}
customCategories={customCategories}
onCustomCategoriesChange={setCustomCategories}
suggestedCount={suggestedCategoryCount}
onSuggestedCountChange={setSuggestedCategoryCount}
step0Result={progress.step0Result}
disabled={loading}
/>
),
},
{
key: 'llm',
label: 'LLM Parameters',

View File

@@ -117,8 +117,19 @@ export const MindmapD3 = forwardRef<MindmapD3Ref, MindmapD3Props>(
d._children = undefined;
});
// Category labels for header
const categoryLabels = ['', '材料', '功能', '用途', '使用族群'];
// Dynamically extract category labels from the tree based on depth
// Each depth level corresponds to a category
const categoryByDepth: Record<number, string> = {};
root.descendants().forEach((d: TreeNode) => {
if (d.depth > 0 && d.data.category && !categoryByDepth[d.depth]) {
categoryByDepth[d.depth] = d.data.category;
}
});
const maxDepthWithCategory = Math.max(...Object.keys(categoryByDepth).map(Number), 0);
const categoryLabels = [''];
for (let i = 1; i <= maxDepthWithCategory; i++) {
categoryLabels.push(categoryByDepth[i] || '');
}
const headerHeight = 40;
function update(source: TreeNode) {
@@ -143,12 +154,27 @@ export const MindmapD3 = forwardRef<MindmapD3Ref, MindmapD3Props>(
// Draw category headers with background
g.selectAll('.category-header-group').remove();
const maxDepth = Math.max(...descendants.map(d => d.depth));
const categoryColors: Record<string, string> = {
'材料': isDark ? '#854eca' : '#722ed1',
'功能': isDark ? '#13a8a8' : '#13c2c2',
'用途': isDark ? '#d87a16' : '#fa8c16',
'使用族群': isDark ? '#49aa19' : '#52c41a',
};
// Dynamic color palette for categories
const colorPalette = [
{ dark: '#854eca', light: '#722ed1' }, // purple
{ dark: '#13a8a8', light: '#13c2c2' }, // cyan
{ dark: '#d87a16', light: '#fa8c16' }, // orange
{ dark: '#49aa19', light: '#52c41a' }, // green
{ dark: '#1677ff', light: '#1890ff' }, // blue
{ dark: '#eb2f96', light: '#f759ab' }, // magenta
{ dark: '#faad14', light: '#ffc53d' }, // gold
{ dark: '#a0d911', light: '#bae637' }, // lime
];
// Generate colors dynamically based on category position
const categoryColors: Record<string, string> = {};
categoryLabels.forEach((label, index) => {
if (label && index > 0) {
const colorIndex = (index - 1) % colorPalette.length;
categoryColors[label] = isDark ? colorPalette[colorIndex].dark : colorPalette[colorIndex].light;
}
});
for (let depth = 1; depth <= Math.min(maxDepth, categoryLabels.length - 1); depth++) {
const label = categoryLabels[depth];

View File

@@ -3,9 +3,9 @@ import type {
AttributeNode,
HistoryItem,
StreamProgress,
StreamAnalyzeResponse,
CausalChain
StreamAnalyzeResponse
} from '../types';
import { CategoryMode } from '../types';
import { analyzeAttributesStream } from '../services/api';
export function useAttribute() {
@@ -24,7 +24,10 @@ export function useAttribute() {
query: string,
model?: string,
temperature?: number,
chainCount: number = 5
chainCount: number = 5,
categoryMode: CategoryMode = CategoryMode.DYNAMIC_AUTO,
customCategories?: string[],
suggestedCategoryCount: number = 3
) => {
// 重置狀態
setProgress({
@@ -39,8 +42,40 @@ export function useAttribute() {
try {
await analyzeAttributesStream(
{ query, chain_count: chainCount, model, temperature },
{
query,
chain_count: chainCount,
model,
temperature,
category_mode: categoryMode,
custom_categories: customCategories,
suggested_category_count: suggestedCategoryCount
},
{
onStep0Start: () => {
setProgress(prev => ({
...prev,
step: 'step0',
message: '正在分析類別...',
}));
},
onStep0Complete: (result) => {
setProgress(prev => ({
...prev,
step0Result: result,
message: '類別分析完成',
}));
},
onCategoriesResolved: (categories) => {
setProgress(prev => ({
...prev,
categoriesUsed: categories,
message: `使用 ${categories.length} 個類別`,
}));
},
onStep1Start: () => {
setProgress(prev => ({
...prev,
@@ -148,7 +183,7 @@ export function useAttribute() {
});
}, []);
const isLoading = progress.step === 'step1' || progress.step === 'chains';
const isLoading = progress.step === 'step0' || progress.step === 'step1' || progress.step === 'chains';
return {
loading: isLoading,

View File

@@ -3,17 +3,24 @@ import type {
StreamAnalyzeRequest,
StreamAnalyzeResponse,
Step1Result,
CausalChain
CausalChain,
Step0Result,
CategoryDefinition,
DynamicStep1Result,
DynamicCausalChain
} from '../types';
// 自動使用當前瀏覽器的 hostname支援遠端存取
const API_BASE_URL = `http://${window.location.hostname}:8000/api`;
export interface SSECallbacks {
onStep0Start?: () => void;
onStep0Complete?: (result: Step0Result) => void;
onCategoriesResolved?: (categories: CategoryDefinition[]) => void;
onStep1Start?: () => void;
onStep1Complete?: (result: Step1Result) => void;
onStep1Complete?: (result: Step1Result | DynamicStep1Result) => void;
onChainStart?: (index: number, total: number) => void;
onChainComplete?: (index: number, chain: CausalChain) => void;
onChainComplete?: (index: number, chain: CausalChain | DynamicCausalChain) => void;
onChainError?: (index: number, error: string) => void;
onDone?: (response: StreamAnalyzeResponse) => void;
onError?: (error: string) => void;
@@ -65,6 +72,15 @@ export async function analyzeAttributesStream(
const eventData = JSON.parse(dataMatch[1]);
switch (eventType) {
case 'step0_start':
callbacks.onStep0Start?.();
break;
case 'step0_complete':
callbacks.onStep0Complete?.(eventData.result);
break;
case 'categories_resolved':
callbacks.onCategoriesResolved?.(eventData.categories);
break;
case 'step1_start':
callbacks.onStep1Start?.();
break;

View File

@@ -126,6 +126,38 @@
fill: #fff;
}
.mindmap-light .node-rect.depth-5 {
fill: #1890ff;
stroke: #096dd9;
}
.mindmap-light .node-text.depth-5 {
fill: #fff;
}
.mindmap-light .node-rect.depth-6 {
fill: #f759ab;
stroke: #eb2f96;
}
.mindmap-light .node-text.depth-6 {
fill: #fff;
}
.mindmap-light .node-rect.depth-7 {
fill: #ffc53d;
stroke: #faad14;
}
.mindmap-light .node-text.depth-7 {
fill: #fff;
}
.mindmap-light .node-rect.depth-8 {
fill: #bae637;
stroke: #a0d911;
}
.mindmap-light .node-text.depth-8 {
fill: #fff;
}
.mindmap-light .link {
stroke: #bfbfbf;
}
@@ -221,6 +253,38 @@
fill: #fff;
}
.mindmap-dark .node-rect.depth-5 {
fill: #1677ff;
stroke: #4096ff;
}
.mindmap-dark .node-text.depth-5 {
fill: #fff;
}
.mindmap-dark .node-rect.depth-6 {
fill: #eb2f96;
stroke: #f759ab;
}
.mindmap-dark .node-text.depth-6 {
fill: #fff;
}
.mindmap-dark .node-rect.depth-7 {
fill: #faad14;
stroke: #ffc53d;
}
.mindmap-dark .node-text.depth-7 {
fill: #fff;
}
.mindmap-dark .node-rect.depth-8 {
fill: #a0d911;
stroke: #bae637;
}
.mindmap-dark .node-text.depth-8 {
fill: #fff;
}
.mindmap-dark .link {
stroke: #434343;
}

View File

@@ -52,26 +52,64 @@ export interface CausalChain {
user: string;
}
// ===== Dynamic category system types =====
export interface CategoryDefinition {
name: string;
description?: string;
is_fixed: boolean;
order: number;
}
export interface Step0Result {
categories: CategoryDefinition[];
}
export interface DynamicStep1Result {
attributes: Record<string, string[]>;
}
export interface DynamicCausalChain {
chain: Record<string, string>;
}
export const CategoryMode = {
FIXED_ONLY: 'fixed_only',
FIXED_PLUS_CUSTOM: 'fixed_plus_custom',
CUSTOM_ONLY: 'custom_only',
DYNAMIC_AUTO: 'dynamic_auto',
} as const;
export type CategoryMode = typeof CategoryMode[keyof typeof CategoryMode];
export interface StreamAnalyzeRequest {
query: string;
model?: string;
temperature?: number;
chain_count: number;
// Dynamic category support
category_mode?: CategoryMode;
custom_categories?: string[];
suggested_category_count?: number;
}
export interface StreamProgress {
step: 'idle' | 'step1' | 'chains' | 'done' | 'error';
step1Result?: Step1Result;
step: 'idle' | 'step0' | 'step1' | 'chains' | 'done' | 'error';
step0Result?: Step0Result;
categoriesUsed?: CategoryDefinition[];
step1Result?: Step1Result | DynamicStep1Result;
currentChainIndex: number;
totalChains: number;
completedChains: CausalChain[];
completedChains: (CausalChain | DynamicCausalChain)[];
message: string;
error?: string;
}
export interface StreamAnalyzeResponse {
query: string;
step1_result: Step1Result;
causal_chains: CausalChain[];
step0_result?: Step0Result;
categories_used: CategoryDefinition[];
step1_result: Step1Result | DynamicStep1Result;
causal_chains: (CausalChain | DynamicCausalChain)[];
attributes: AttributeNode;
}