Skip to content

Commit 396eb4a

Browse files
committed
Add building data model for source, relation, observation and analysis layers
1 parent b1e413e commit 396eb4a

6 files changed

Lines changed: 569 additions & 3 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createBuildingRecord, createEmptyBuildingStudy, normalizeBuildingStudy } from "./buildingDataModel";
3+
import { createLocalProject, normalizeWorkspace } from "./model";
4+
5+
describe("building data model", () => {
6+
it("keeps source values, calculated values, observations and analysis as separate layers", () => {
7+
const study = createEmptyBuildingStudy();
8+
const record = createBuildingRecord({ id: "b-1", centroid: { latitude: 35, longitude: 126 }, distanceToSiteMeters: 20, footprintAreaSqm: 120, sourceRefIds: ["raw-1"] });
9+
study.records.push(record);
10+
expect(record.footprintAreaSqm.status).toBe("calculated");
11+
expect(record.scopeMembership).toEqual(["macro", "meso", "site", "micro"]);
12+
expect(study.rawReferences).toEqual([]);
13+
expect(study.observationLinks).toEqual([]);
14+
expect(study.analyses).toEqual([]);
15+
});
16+
17+
it("normalizes missing building study for legacy projects", () => {
18+
const project = createLocalProject("legacy");
19+
const legacy = { ...project } as Record<string, unknown>;
20+
delete legacy.buildingStudy;
21+
const workspace = normalizeWorkspace({ schemaVersion: 1, activeProjectId: project.id, projects: [legacy] });
22+
expect(workspace?.projects[0].buildingStudy?.records).toEqual([]);
23+
expect(workspace?.projects[0].buildingStudy?.scopeConfig.macroMeters).toBe(1000);
24+
});
25+
26+
it("preserves unknown and conflict states instead of converting them to facts", () => {
27+
const study = normalizeBuildingStudy({ records: [{ id: "b-2", matchStatus: "conflict", matchConfidence: "candidate", footprintAreaSqm: { value: null, status: "unknown", sourceRefIds: [] } }], analyses: [] });
28+
expect(study.records[0].matchStatus).toBe("conflict");
29+
expect(study.records[0].footprintAreaSqm.status).toBe("unknown");
30+
});
31+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { SpatialGeometry } from "./model";
2+
import { buildingScopeMembership, defaultBuildingScopeConfig, normalizeBuildingScopeConfig, type BuildingScope, type BuildingScopeConfig } from "./buildingScope";
3+
4+
export type BuildingValueStatus = "verified" | "calculated" | "candidate" | "unknown" | "conflict";
5+
export type BuildingFieldName = "buildingManagementNo" | "pnu" | "address" | "buildingName" | "primaryUse" | "secondaryUses" | "aboveGroundFloors" | "belowGroundFloors" | "heightMeters" | "buildingAreaSqm" | "grossFloorAreaSqm" | "coverageRatio" | "floorAreaRatio" | "structure" | "approvalDate" | "completionDate" | "demolitionDate";
6+
7+
export type BuildingValue = {
8+
value: unknown;
9+
status: BuildingValueStatus;
10+
sourceRefIds: string[];
11+
rawFieldNames?: string[];
12+
note?: string;
13+
};
14+
15+
export type BuildingRawReference = {
16+
id: string;
17+
source: string;
18+
dataset: string;
19+
sourceUrl?: string;
20+
featureId?: string;
21+
retrievedAt: string;
22+
dataDate?: string;
23+
originalCrs?: string;
24+
rawLocation: "researchNote" | "file" | "inline";
25+
rawFieldNames: string[];
26+
};
27+
28+
export type BuildingRecord = {
29+
id: string;
30+
geometry: SpatialGeometry | null;
31+
centroid: { latitude: number; longitude: number } | null;
32+
footprintAreaSqm: BuildingValue;
33+
scopeMembership: BuildingScope[];
34+
fields: Partial<Record<BuildingFieldName, BuildingValue>>;
35+
sourceRefIds: string[];
36+
matchStatus: "unmatched" | "candidate" | "matched" | "conflict";
37+
matchConfidence: "unknown" | "candidate" | "partial" | "strong" | "exact";
38+
observationIds: string[];
39+
};
40+
41+
export type BuildingRelation = {
42+
id: string;
43+
buildingId: string;
44+
siteDistanceMeters: number | null;
45+
boundaryDistanceMeters: number | null;
46+
nearestBoundarySide: "north" | "east" | "south" | "west" | "unknown";
47+
overlapWithSite: boolean;
48+
nearestBuildingIds: string[];
49+
scopeMembership: BuildingScope[];
50+
relationStatus: "calculated" | "unknown" | "conflict";
51+
calculatedAt: string;
52+
};
53+
54+
export type BuildingObservationLink = {
55+
observationId: string;
56+
buildingId: string;
57+
relationType: "entrance" | "frontage" | "facade" | "window" | "canopy" | "vacancy" | "material" | "activity" | "boundary" | "contradiction";
58+
photoId?: string;
59+
overlayId?: string;
60+
};
61+
62+
export type BuildingAnalysisClaim = {
63+
id: string;
64+
text: string;
65+
evidenceIds: string[];
66+
status: "fact" | "relation" | "interpretation" | "unknown" | "hypothesis";
67+
scope?: BuildingScope;
68+
};
69+
70+
export type BuildingHypothesis = {
71+
id: string;
72+
title: string;
73+
evidenceIds: string[];
74+
interpretation: string;
75+
spatialAction: string;
76+
experience: string;
77+
advantages: string[];
78+
risks: string[];
79+
verificationQuestions: string[];
80+
};
81+
82+
export type BuildingAnalysis = {
83+
id: string;
84+
catalogId: "buildings";
85+
scopeSummary: Partial<Record<BuildingScope, string>>;
86+
verifiedFacts: BuildingAnalysisClaim[];
87+
relations: BuildingAnalysisClaim[];
88+
interpretations: BuildingAnalysisClaim[];
89+
unknowns: BuildingAnalysisClaim[];
90+
keywords: string[];
91+
issues: BuildingAnalysisClaim[];
92+
fieldQuestions: string[];
93+
designQuestions: string[];
94+
hypotheses: BuildingHypothesis[];
95+
sourceEvidenceIds: string[];
96+
createdAt: string;
97+
updatedAt: string;
98+
};
99+
100+
export type BuildingStudy = {
101+
scopeConfig: BuildingScopeConfig;
102+
rawReferences: BuildingRawReference[];
103+
records: BuildingRecord[];
104+
relations: BuildingRelation[];
105+
observationLinks: BuildingObservationLink[];
106+
analyses: BuildingAnalysis[];
107+
updatedAt: string;
108+
};
109+
110+
const emptyValue = (status: BuildingValueStatus = "unknown"): BuildingValue => ({ value: null, status, sourceRefIds: [] });
111+
112+
export function createEmptyBuildingStudy(config: Partial<BuildingScopeConfig> = {}): BuildingStudy {
113+
return { scopeConfig: normalizeBuildingScopeConfig(config), rawReferences: [], records: [], relations: [], observationLinks: [], analyses: [], updatedAt: new Date().toISOString() };
114+
}
115+
116+
export function createBuildingRecord(input: { id: string; geometry?: SpatialGeometry | null; centroid?: { latitude: number; longitude: number } | null; distanceToSiteMeters?: number; footprintAreaSqm?: number | null; sourceRefIds?: string[]; matchStatus?: BuildingRecord["matchStatus"]; matchConfidence?: BuildingRecord["matchConfidence"]; scopeConfig?: Partial<BuildingScopeConfig> }): BuildingRecord {
117+
const sourceRefIds = input.sourceRefIds ?? [];
118+
const footprint = input.footprintAreaSqm !== undefined && input.footprintAreaSqm !== null && Number.isFinite(input.footprintAreaSqm) ? { value: input.footprintAreaSqm, status: "calculated" as const, sourceRefIds } : emptyValue();
119+
return { id: input.id, geometry: input.geometry ?? null, centroid: input.centroid ?? null, footprintAreaSqm: footprint, scopeMembership: input.distanceToSiteMeters === undefined ? [] : buildingScopeMembership(input.distanceToSiteMeters, input.scopeConfig), fields: {}, sourceRefIds, matchStatus: input.matchStatus ?? "unmatched", matchConfidence: input.matchConfidence ?? "unknown", observationIds: [] };
120+
}
121+
122+
export function normalizeBuildingStudy(value: unknown): BuildingStudy {
123+
if (!value || typeof value !== "object") return createEmptyBuildingStudy(defaultBuildingScopeConfig);
124+
const input = value as Partial<BuildingStudy>;
125+
return { scopeConfig: normalizeBuildingScopeConfig(input.scopeConfig ?? defaultBuildingScopeConfig), rawReferences: Array.isArray(input.rawReferences) ? input.rawReferences : [], records: Array.isArray(input.records) ? input.records : [], relations: Array.isArray(input.relations) ? input.relations : [], observationLinks: Array.isArray(input.observationLinks) ? input.observationLinks : [], analyses: Array.isArray(input.analyses) ? input.analyses : [], updatedAt: typeof input.updatedAt === "string" ? input.updatedAt : new Date().toISOString() };
126+
}

client/src/static/model.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createEmptyBuildingStudy, normalizeBuildingStudy, type BuildingStudy } from "./buildingDataModel";
2+
13
export type LlmProvider = "openai" | "gemini" | "anthropic";
24

35
export type BoundaryPoint = { lat: number; lng: number };
@@ -21,6 +23,7 @@ export type LocalProject = {
2123
lenses: string[];
2224
site: SiteRecord;
2325
researchPlan?: ResearchPlan;
26+
buildingStudy?: BuildingStudy;
2427
observations: Observation[];
2528
researchNotes: ResearchNote[];
2629
studyRadiusMeters: number;
@@ -42,7 +45,7 @@ const createId = () => globalThis.crypto?.randomUUID?.() ?? `site-${Date.now()}-
4245

4346
export function createLocalProject(title = "새 대지조사"): LocalProject {
4447
const timestamp = now();
45-
return { schemaVersion: 1, id: createId(), title, lenses: [], site: { address: "", latitude: 35.1467, longitude: 126.921, boundary: [] }, observations: [], researchNotes: [], studyRadiusMeters: 300, overlays: [], spatialLayers: [], designNotes: [], createdAt: timestamp, updatedAt: timestamp };
48+
return { schemaVersion: 1, id: createId(), title, lenses: [], site: { address: "", latitude: 35.1467, longitude: 126.921, boundary: [] }, buildingStudy: createEmptyBuildingStudy(), observations: [], researchNotes: [], studyRadiusMeters: 300, overlays: [], spatialLayers: [], designNotes: [], createdAt: timestamp, updatedAt: timestamp };
4649
}
4750

4851
export function createWorkspace(project = createLocalProject()): StoredWorkspace {
@@ -55,7 +58,7 @@ function isPoint(value: unknown): value is BoundaryPoint {
5558

5659
export function normalizeWorkspace(value: unknown): StoredWorkspace | null {
5760
if (!value || typeof value !== "object" || (value as { schemaVersion?: unknown }).schemaVersion !== 1 || !Array.isArray((value as StoredWorkspace).projects)) return null;
58-
const projects = (value as StoredWorkspace).projects.filter((project): project is LocalProject => Boolean(project && typeof project.id === "string" && typeof project.title === "string" && project.site && Array.isArray(project.site.boundary) && project.site.boundary.every(isPoint) )).map(project => ({ ...project, site: { ...project.site, parcels: Array.isArray(project.site.parcels) ? project.site.parcels.filter(item => item && typeof item === "object") : undefined }, lenses: Array.isArray(project.lenses) ? project.lenses.filter(item => typeof item === "string") : [], observations: Array.isArray(project.observations) ? project.observations : [], researchNotes: Array.isArray(project.researchNotes) ? project.researchNotes : [], studyRadiusMeters: Number.isFinite(project.studyRadiusMeters) ? Math.min(3000, Math.max(50, project.studyRadiusMeters)) : 300, overlays: Array.isArray(project.overlays) ? project.overlays.filter(item => item && Number.isFinite(item.latitude) && Number.isFinite(item.longitude)) : [], spatialLayers: Array.isArray(project.spatialLayers) ? project.spatialLayers.filter(item => item && typeof item.id === "string" && Array.isArray(item.features)) : [], designNotes: Array.isArray(project.designNotes) ? project.designNotes : [] }));
61+
const projects = (value as StoredWorkspace).projects.filter((project): project is LocalProject => Boolean(project && typeof project.id === "string" && typeof project.title === "string" && project.site && Array.isArray(project.site.boundary) && project.site.boundary.every(isPoint) )).map(project => ({ ...project, site: { ...project.site, parcels: Array.isArray(project.site.parcels) ? project.site.parcels.filter(item => item && typeof item === "object") : undefined }, lenses: Array.isArray(project.lenses) ? project.lenses.filter(item => typeof item === "string") : [], buildingStudy: normalizeBuildingStudy(project.buildingStudy), observations: Array.isArray(project.observations) ? project.observations : [], researchNotes: Array.isArray(project.researchNotes) ? project.researchNotes : [], studyRadiusMeters: Number.isFinite(project.studyRadiusMeters) ? Math.min(3000, Math.max(50, project.studyRadiusMeters)) : 300, overlays: Array.isArray(project.overlays) ? project.overlays.filter(item => item && Number.isFinite(item.latitude) && Number.isFinite(item.longitude)) : [], spatialLayers: Array.isArray(project.spatialLayers) ? project.spatialLayers.filter(item => item && typeof item.id === "string" && Array.isArray(item.features)) : [], designNotes: Array.isArray(project.designNotes) ? project.designNotes : [] }));
5962
if (!projects.length) return null;
6063
const activeProjectId = projects.some(project => project.id === (value as StoredWorkspace).activeProjectId) ? (value as StoredWorkspace).activeProjectId : projects[0].id;
6164
return { schemaVersion: 1, activeProjectId, projects };

development/TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
- [-] B-01. 현재 건축물 자료원과 QGIS 처리 방식 조사
2020
- [x] B-02. 거시·중간·대지·미시 조사 범위와 자료량 정책 확정
21-
- [ ] B-03. 원본·정규화·현장관찰·분석결과 데이터 구조 확정
21+
- [x] B-03. 원본·정규화·현장관찰·분석결과 데이터 구조 확정
2222
- [ ] B-04. 건축물 식별자와 자료 간 결합 정책 확정
2323

2424
## C. 주변 건축물 데이터 연결·정규화
@@ -75,3 +75,4 @@
7575
| 2026-08-28 | 전체 개발 TODO와 진행 규칙 작성 | 완료 | 사용자 확인 완료 | 건축물 분야부터 시작 |
7676
| 2026-08-29 | B-01 현재 건축물 자료원·QGIS 처리 방식 조사 | 완료 | 감사 문서 작성·검증 완료 | B-02·B-03에서 결합·범위 정책 설계 |
7777
| 2026-08-29 | B-02 거시·중간·대지·미시 조사 범위와 자료량 정책 | 완료 | 범위 정책 함수·필드 정책·테스트 통과 | B-03 데이터 구조에서 적용 |
78+
| 2026-08-29 | B-03 원본·정규화·현장관찰·분석결과 데이터 구조 | 완료 | 데이터 타입·프로젝트 연결·역호환 테스트 통과 | B-04 식별자 결합에서 적용 |
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# 주변 건축물 데이터 모델 설계
2+
3+
작성일: 2026-08-29
4+
상위 작업: `development/tasks/B-03-building-data-model.md`
5+
6+
## 계층
7+
8+
```text
9+
원본 참조
10+
→ 건축물 정규화 레코드
11+
→ 대지·건축물 공간관계
12+
→ 현장관찰 연결
13+
→ 건축물 분야 분석결과
14+
```
15+
16+
원본 응답·파일은 `ResearchNote.detail/rawData` 또는 파일 참조로 보존하고, `BuildingRecord`에는 원본 자체를 중복 저장하지 않고 출처 참조 ID를 저장한다.
17+
18+
## 값 상태
19+
20+
```text
21+
verified 출처 원자료에서 확인
22+
calculated geometry·속성으로 프로그램이 계산
23+
candidate 결합 후보
24+
unknown 자료 없음·미확인
25+
conflict 복수 출처 값 충돌
26+
```
27+
28+
상태가 `unknown` 또는 `candidate`인 값을 FACT처럼 AI에 전달하지 않는다.
29+
30+
## 주요 타입
31+
32+
### BuildingRawReference
33+
34+
```text
35+
id
36+
source
37+
dataset
38+
sourceUrl
39+
featureId
40+
retrievedAt
41+
dataDate
42+
originalCrs
43+
rawLocation
44+
rawFieldNames
45+
```
46+
47+
### BuildingRecord
48+
49+
```text
50+
id
51+
geometry
52+
centroid
53+
footprintAreaSqm: BuildingValue
54+
scopeMembership
55+
fields
56+
sourceRefIds
57+
matchStatus
58+
matchConfidence
59+
observationIds
60+
```
61+
62+
`fields`에는 다음 항목을 넣을 수 있다.
63+
64+
```text
65+
buildingManagementNo
66+
pnu
67+
address
68+
buildingName
69+
primaryUse
70+
secondaryUses
71+
aboveGroundFloors
72+
belowGroundFloors
73+
heightMeters
74+
buildingAreaSqm
75+
grossFloorAreaSqm
76+
coverageRatio
77+
floorAreaRatio
78+
structure
79+
approvalDate
80+
completionDate
81+
demolitionDate
82+
```
83+
84+
### BuildingRelation
85+
86+
```text
87+
buildingId
88+
siteDistanceMeters
89+
boundaryDistanceMeters
90+
nearestBoundarySide
91+
overlapWithSite
92+
nearestBuildingIds
93+
scopeMembership
94+
relationStatus
95+
calculatedAt
96+
```
97+
98+
이 구조는 공간 계산 결과만 보관한다. 법적 접도·인허가 판정은 보관하지 않는다.
99+
100+
### BuildingObservationLink
101+
102+
현장관찰은 기존 `Observation`과 연결한다.
103+
104+
```text
105+
observationId
106+
buildingId
107+
relationType
108+
photoId
109+
overlayId
110+
```
111+
112+
관찰 유형:
113+
114+
```text
115+
entrance, frontage, facade, window, canopy,
116+
vacancy, material, activity, boundary, contradiction
117+
```
118+
119+
### BuildingAnalysis
120+
121+
```text
122+
catalogId: buildings
123+
scopeSummary
124+
verifiedFacts
125+
relations
126+
interpretations
127+
unknowns
128+
keywords
129+
issues
130+
fieldQuestions
131+
designQuestions
132+
hypotheses
133+
sourceEvidenceIds
134+
createdAt
135+
updatedAt
136+
```
137+
138+
분석 문장은 `BuildingAnalysisClaim`으로 저장하고 `evidenceIds`를 요구한다.
139+
140+
## 프로젝트 연결
141+
142+
`LocalProject`에 다음 선택적 필드를 추가했다.
143+
144+
```text
145+
buildingStudy?: {
146+
scopeConfig
147+
rawReferences
148+
records
149+
relations
150+
observationLinks
151+
analyses
152+
updatedAt
153+
}
154+
```
155+
156+
새 프로젝트에는 빈 `buildingStudy`가 생성되고, 기존 프로젝트 파일에는 값이 없더라도 기본 빈 구조로 정규화된다.
157+
158+
## 범위 정책 연결
159+
160+
`buildingScope.ts`의 기본 범위와 연결한다.
161+
162+
```text
163+
macro 1,000m 이하
164+
meso 300m 이하
165+
site 100m 이하
166+
micro 30m 이하
167+
```
168+
169+
한 건축물은 누적 멤버십을 가진다. 예를 들어 20m 건축물은 네 범위에 모두 포함되지만, 실제 레코드는 한 건만 보관한다.
170+
171+
## 다음 작업에서 사용할 것
172+
173+
```text
174+
B-04 건축물 ID·속성 결합 정책
175+
C-01 VWorld footprint 원자료 품질검사
176+
C-02 용도별건물정보·건축HUB 속성 연결
177+
```

0 commit comments

Comments
 (0)